diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/factories/AutoCompleteViewHolderFactoryEspressoTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/AutoCompleteViewHolderFactoryEspressoTest.kt similarity index 60% rename from datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/factories/AutoCompleteViewHolderFactoryEspressoTest.kt rename to datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/AutoCompleteViewHolderFactoryEspressoTest.kt index 87f38fd25b..f9c7f3f637 100644 --- a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/factories/AutoCompleteViewHolderFactoryEspressoTest.kt +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/AutoCompleteViewHolderFactoryEspressoTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 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. @@ -14,26 +14,30 @@ * limitations under the License. */ -package com.google.android.fhir.datacapture.views.factories +package com.google.android.fhir.datacapture.test.views -import android.view.View -import android.widget.AutoCompleteTextView import android.widget.FrameLayout -import android.widget.TextView -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions -import androidx.test.espresso.assertion.ViewAssertions -import androidx.test.espresso.matcher.RootMatchers -import androidx.test.espresso.matcher.ViewMatchers +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasTextExactly +import androidx.compose.ui.test.isPopup +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextReplacement import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.platform.app.InstrumentationRegistry -import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.test.TestActivity -import com.google.android.fhir.datacapture.test.utilities.delayMainThread import com.google.android.fhir.datacapture.validation.NotValidated import com.google.android.fhir.datacapture.views.QuestionnaireViewItem -import com.google.android.material.chip.ChipGroup -import com.google.android.material.textfield.MaterialAutoCompleteTextView +import com.google.android.fhir.datacapture.views.compose.DROP_DOWN_ANSWER_MENU_ITEM_TAG +import com.google.android.fhir.datacapture.views.compose.MULTI_AUTO_COMPLETE_INPUT_CHIP_TAG +import com.google.android.fhir.datacapture.views.compose.MULTI_AUTO_COMPLETE_TEXT_FIELD_TAG +import com.google.android.fhir.datacapture.views.factories.AutoCompleteViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder import com.google.common.truth.Truth.assertThat import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.Questionnaire @@ -43,19 +47,22 @@ import org.junit.Rule import org.junit.Test class AutoCompleteViewHolderFactoryEspressoTest { - @Rule - @JvmField + @get:Rule var activityScenarioRule: ActivityScenarioRule = ActivityScenarioRule(TestActivity::class.java) - private lateinit var parent: FrameLayout + @get:Rule val composeTestRule = createEmptyComposeRule() + private lateinit var viewHolder: QuestionnaireItemViewHolder @Before fun setup() { - activityScenarioRule.scenario.onActivity { activity -> parent = FrameLayout(activity) } - viewHolder = AutoCompleteViewHolderFactory.create(parent) - setTestLayout(viewHolder.itemView) + activityScenarioRule.scenario.onActivity { activity -> + viewHolder = AutoCompleteViewHolderFactory.create(FrameLayout(activity)) + activity.setContentView(viewHolder.itemView) + } + + InstrumentationRegistry.getInstrumentation().waitForIdleSync() } @Test @@ -67,16 +74,11 @@ class AutoCompleteViewHolderFactoryEspressoTest { validationResult = NotValidated, answersChangedCallback = { _, _, _, _ -> }, ) - runOnUI { viewHolder.bind(questionnaireViewItem) } - - onView(ViewMatchers.withId(R.id.autoCompleteTextView)).perform(ViewActions.typeText("Coding 1")) - assertThat( - viewHolder.itemView - .findViewById(R.id.autoCompleteTextView) - .adapter - .count, - ) - .isEqualTo(1) + viewHolder.bind(questionnaireViewItem) + composeTestRule + .onNodeWithTag(MULTI_AUTO_COMPLETE_TEXT_FIELD_TAG) + .performTextReplacement("Coding 1") + composeTestRule.onAllNodes(hasTestTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG)).assertCountEquals(1) } @Test @@ -89,23 +91,24 @@ class AutoCompleteViewHolderFactoryEspressoTest { validationResult = NotValidated, answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, ) - runOnUI { viewHolder.bind(questionnaireViewItem) } + viewHolder.bind(questionnaireViewItem) - onView(ViewMatchers.withId(R.id.autoCompleteTextView)).perform(ViewActions.typeText("Coding 3")) - runOnUI { - viewHolder.itemView - .findViewById(R.id.autoCompleteTextView) - .showDropDown() - } - onView(ViewMatchers.withId(R.id.autoCompleteTextView)).perform(delayMainThread()) - onView(ViewMatchers.withText("Coding 3")) - .inRoot(RootMatchers.isPlatformPopup()) - .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) - .perform(ViewActions.click()) - assertThat( - viewHolder.itemView.findViewById(R.id.autoCompleteTextView).text.toString(), + composeTestRule + .onNodeWithTag(MULTI_AUTO_COMPLETE_TEXT_FIELD_TAG) + .performTextReplacement("Coding 3") + + composeTestRule + .onNode( + hasTestTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG) and + hasTextExactly("Coding 3") and + hasAnyAncestor(isPopup()), ) - .isEmpty() + .assertIsDisplayed() + .performClick() + + composeTestRule.onNodeWithTag(MULTI_AUTO_COMPLETE_TEXT_FIELD_TAG).assertTextEquals("") + + composeTestRule.waitUntil { answerHolder != null } assertThat(answerHolder!!.map { it.valueCoding.display }) .containsExactly("Coding 1", "Coding 5", "Coding 3") } @@ -119,21 +122,8 @@ class AutoCompleteViewHolderFactoryEspressoTest { validationResult = NotValidated, answersChangedCallback = { _, _, _, _ -> }, ) - runOnUI { viewHolder.bind(questionnaireViewItem) } - - assertThat(viewHolder.itemView.findViewById(R.id.chipContainer).childCount) - .isEqualTo(2) - } - - /** Method to run code snippet on UI/main thread */ - private fun runOnUI(action: () -> Unit) { - activityScenarioRule.scenario.onActivity { action() } - } - - /** Method to set content view for test activity */ - private fun setTestLayout(view: View) { - activityScenarioRule.scenario.onActivity { activity -> activity.setContentView(view) } - InstrumentationRegistry.getInstrumentation().waitForIdleSync() + viewHolder.bind(questionnaireViewItem) + composeTestRule.onAllNodes(hasTestTag(MULTI_AUTO_COMPLETE_INPUT_CHIP_TAG)).assertCountEquals(2) } private fun answerOptions(repeats: Boolean, vararg options: String) = diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/AutoCompleteViewHolderFactoryTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/AutoCompleteViewHolderFactoryTest.kt similarity index 73% rename from datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/AutoCompleteViewHolderFactoryTest.kt rename to datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/AutoCompleteViewHolderFactoryTest.kt index 04d63a634b..a9aca1ddcb 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/AutoCompleteViewHolderFactoryTest.kt +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/AutoCompleteViewHolderFactoryTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 Google LLC + * Copyright 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. @@ -14,42 +14,68 @@ * limitations under the License. */ -package com.google.android.fhir.datacapture.views.factories +package com.google.android.fhir.datacapture.test.views -import android.view.View import android.widget.FrameLayout import android.widget.TextView -import androidx.appcompat.app.AppCompatActivity +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.onNodeWithTag import androidx.core.view.get +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.R import com.google.android.fhir.datacapture.extensions.displayString import com.google.android.fhir.datacapture.extensions.identifierString +import com.google.android.fhir.datacapture.test.TestActivity import com.google.android.fhir.datacapture.validation.Invalid import com.google.android.fhir.datacapture.validation.NotValidated import com.google.android.fhir.datacapture.validation.Valid import com.google.android.fhir.datacapture.views.QuestionTextConfiguration import com.google.android.fhir.datacapture.views.QuestionnaireViewItem -import com.google.android.material.chip.Chip -import com.google.android.material.chip.ChipGroup -import com.google.android.material.textfield.TextInputLayout +import com.google.android.fhir.datacapture.views.compose.ERROR_TEXT_AT_HEADER_TEST_TAG +import com.google.android.fhir.datacapture.views.compose.MULTI_AUTO_COMPLETE_INPUT_CHIP_TAG +import com.google.android.fhir.datacapture.views.compose.MULTI_AUTO_COMPLETE_TEXT_FIELD_TAG +import com.google.android.fhir.datacapture.views.compose.REQUIRED_OPTIONAL_HEADER_TEXT_TAG +import com.google.android.fhir.datacapture.views.factories.AutoCompleteViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder import com.google.common.truth.Truth.assertThat import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.junit.Before +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.robolectric.Robolectric -import org.robolectric.RobolectricTestRunner -@RunWith(RobolectricTestRunner::class) +@RunWith(AndroidJUnit4::class) class AutoCompleteViewHolderFactoryTest { - private val parent = - FrameLayout( - Robolectric.buildActivity(AppCompatActivity::class.java).create().get().apply { - setTheme(com.google.android.material.R.style.Theme_Material3_DayNight) - }, - ) - private val viewHolder = AutoCompleteViewHolderFactory.create(parent) + @get:Rule + val activityScenarioRule: ActivityScenarioRule = + ActivityScenarioRule(TestActivity::class.java) + + @get:Rule val composeTestRule = createEmptyComposeRule() + + private lateinit var viewHolder: QuestionnaireItemViewHolder + + @Before + fun setUp() { + activityScenarioRule.scenario.onActivity { activity -> + viewHolder = AutoCompleteViewHolderFactory.create(FrameLayout(activity)) + activity.setContentView(viewHolder.itemView) + } + + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + } @Test fun shouldSetQuestionHeader() { @@ -62,6 +88,9 @@ class AutoCompleteViewHolderFactoryTest { ), ) + // Synchronize + composeTestRule.waitForIdle() + assertThat(viewHolder.itemView.findViewById(R.id.question).text.toString()) .isEqualTo("Question") } @@ -80,6 +109,7 @@ class AutoCompleteViewHolderFactoryTest { .setValue(Coding().setCode("test2-code").setDisplay("Test2 Code")), ) } + viewHolder.bind( QuestionnaireViewItem( questionnaireItem, @@ -88,7 +118,7 @@ class AutoCompleteViewHolderFactoryTest { QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = questionnaireItem.answerOption - .first { it.value.displayString(parent.context) == "Test1 Code" } + .first { it.value.displayString(viewHolder.itemView.context) == "Test1 Code" } .valueCoding }, ) @@ -98,8 +128,7 @@ class AutoCompleteViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.chipContainer).childCount) - .isEqualTo(1) + composeTestRule.onAllNodes(hasTestTag(MULTI_AUTO_COMPLETE_INPUT_CHIP_TAG)).assertCountEquals(1) } @Test @@ -132,14 +161,18 @@ class AutoCompleteViewHolderFactoryTest { addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = - answers.first { it.value.displayString(parent.context) == "Test1 Code" }.valueCoding + answers + .first { it.value.displayString(viewHolder.itemView.context) == "Test1 Code" } + .valueCoding }, ) addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = - answers.first { it.value.displayString(parent.context) == "Test2 Code" }.valueCoding + answers + .first { it.value.displayString(viewHolder.itemView.context) == "Test2 Code" } + .valueCoding }, ) }, @@ -149,8 +182,7 @@ class AutoCompleteViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.chipContainer).childCount) - .isEqualTo(2) + composeTestRule.onAllNodes(hasTestTag(MULTI_AUTO_COMPLETE_INPUT_CHIP_TAG)).assertCountEquals(2) } @Test @@ -198,7 +230,7 @@ class AutoCompleteViewHolderFactoryTest { value = answers .first { - it.value.identifierString(parent.context) == + it.value.identifierString(viewHolder.itemView.context) == "http://answers/test-codes1.0|test2-code" } .valueCoding @@ -211,8 +243,7 @@ class AutoCompleteViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.chipContainer).childCount) - .isEqualTo(2) + composeTestRule.onAllNodes(hasTestTag(MULTI_AUTO_COMPLETE_INPUT_CHIP_TAG)).assertCountEquals(2) } @Test @@ -245,7 +276,9 @@ class AutoCompleteViewHolderFactoryTest { addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = - answers.first { it.value.displayString(parent.context) == "Test1 Code" }.valueCoding + answers + .first { it.value.displayString(viewHolder.itemView.context) == "Test1 Code" } + .valueCoding }, ) }, @@ -255,8 +288,7 @@ class AutoCompleteViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.chipContainer).childCount) - .isEqualTo(1) + composeTestRule.onAllNodes(hasTestTag(MULTI_AUTO_COMPLETE_INPUT_CHIP_TAG)).assertCountEquals(1) } @Test @@ -284,8 +316,10 @@ class AutoCompleteViewHolderFactoryTest { ), ) - assertThat((viewHolder.itemView.findViewById(R.id.chipContainer)[0] as Chip).text) - .isEqualTo("test1-code") + composeTestRule + .onAllNodes(hasTestTag(MULTI_AUTO_COMPLETE_INPUT_CHIP_TAG)) + .onFirst() + .assertTextEquals("test1-code") } @Test @@ -299,12 +333,14 @@ class AutoCompleteViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.error).visibility) - .isEqualTo(View.VISIBLE) - assertThat(viewHolder.itemView.findViewById(R.id.error).text) - .isEqualTo("Missing answer for required field.") - assertThat(viewHolder.itemView.findViewById(R.id.text_input_layout).error) - .isNotNull() + composeTestRule + .onNodeWithTag(MULTI_AUTO_COMPLETE_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.Error, + "Missing answer for required field.", + ), + ) } @Test @@ -331,14 +367,17 @@ class AutoCompleteViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.error).visibility) - .isEqualTo(View.GONE) - assertThat(viewHolder.itemView.findViewById(R.id.text_input_layout).error) - .isNull() + composeTestRule + .onNodeWithTag(MULTI_AUTO_COMPLETE_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.keyNotDefined( + SemanticsProperties.Error, + ), + ) } @Test - fun `hides error textview in the header`() { + fun hidesErrorTextviewInTheHeader() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), @@ -348,12 +387,14 @@ class AutoCompleteViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.error_text_at_header).visibility) - .isEqualTo(View.GONE) + composeTestRule + .onNodeWithTag(ERROR_TEXT_AT_HEADER_TEST_TAG) + .assertIsNotDisplayed() + .assertDoesNotExist() } @Test - fun `show asterisk`() { + fun showAsterisk() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -367,12 +408,15 @@ class AutoCompleteViewHolderFactoryTest { ), ) + // Synchronize + composeTestRule.waitForIdle() + assertThat(viewHolder.itemView.findViewById(R.id.question).text.toString()) .isEqualTo("Question *") } @Test - fun `hide asterisk`() { + fun hideAsterisk() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -386,12 +430,15 @@ class AutoCompleteViewHolderFactoryTest { ), ) + // Synchronize + composeTestRule.waitForIdle() + assertThat(viewHolder.itemView.findViewById(R.id.question).text.toString()) .isEqualTo("Question") } @Test - fun `shows required text`() { + fun showsRequiredText() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { required = true }, @@ -402,14 +449,14 @@ class AutoCompleteViewHolderFactoryTest { ), ) - assertThat( - viewHolder.itemView.findViewById(R.id.required_optional_text).text.toString(), - ) - .isEqualTo("Required") + composeTestRule + .onNodeWithTag(REQUIRED_OPTIONAL_HEADER_TEXT_TAG) + .assertIsDisplayed() + .assertTextEquals("Required") } @Test - fun `hide required text`() { + fun hideRequiredText() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { required = true }, @@ -420,16 +467,14 @@ class AutoCompleteViewHolderFactoryTest { ), ) - assertThat( - viewHolder.itemView.findViewById(R.id.required_optional_text).text.toString(), - ) - .isEmpty() - assertThat(viewHolder.itemView.findViewById(R.id.required_optional_text).visibility) - .isEqualTo(View.GONE) + composeTestRule + .onNodeWithTag(REQUIRED_OPTIONAL_HEADER_TEXT_TAG) + .assertIsNotDisplayed() + .assertDoesNotExist() } @Test - fun `shows optional text`() { + fun showsOptionalText() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { text = "Question" }, @@ -440,14 +485,14 @@ class AutoCompleteViewHolderFactoryTest { ), ) - assertThat( - viewHolder.itemView.findViewById(R.id.required_optional_text).text.toString(), - ) - .isEqualTo("Optional") + composeTestRule + .onNodeWithTag(REQUIRED_OPTIONAL_HEADER_TEXT_TAG) + .assertIsDisplayed() + .assertTextEquals("Optional") } @Test - fun `hide optional text`() { + fun hideOptionalText() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { text = "Question" }, @@ -458,11 +503,9 @@ class AutoCompleteViewHolderFactoryTest { ), ) - assertThat( - viewHolder.itemView.findViewById(R.id.required_optional_text).text.toString(), - ) - .isEmpty() - assertThat(viewHolder.itemView.findViewById(R.id.required_optional_text).visibility) - .isEqualTo(View.GONE) + composeTestRule + .onNodeWithTag(REQUIRED_OPTIONAL_HEADER_TEXT_TAG) + .assertIsNotDisplayed() + .assertDoesNotExist() } } diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/Header.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/Header.kt index b889492688..3e0d57a363 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/Header.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/Header.kt @@ -147,7 +147,11 @@ internal fun Header( // Required/Optional Text if (showRequiredOrOptionalText && !requiredOptionalText.isNullOrBlank()) { - Text(text = requiredOptionalText, style = MaterialTheme.typography.bodyMedium) + Text( + text = requiredOptionalText, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.testTag(REQUIRED_OPTIONAL_HEADER_TEXT_TAG), + ) } // Validation Error @@ -316,6 +320,7 @@ internal fun Help( } const val ERROR_TEXT_AT_HEADER_TEST_TAG = "error_text_at_header" +const val REQUIRED_OPTIONAL_HEADER_TEXT_TAG = "required_optional_header_text" const val HELP_BUTTON_TAG = "helpButton" const val HELP_CARD_TAG = "helpCardView" const val HEADER_TAG = "headerView" diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/MultiAutoCompleteTextItem.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/MultiAutoCompleteTextItem.kt new file mode 100644 index 0000000000..69a96c165b --- /dev/null +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/MultiAutoCompleteTextItem.kt @@ -0,0 +1,191 @@ +/* + * Copyright 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.views.compose + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.Icon +import androidx.compose.material3.InputChip +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.error +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import com.google.android.fhir.datacapture.R +import com.google.android.fhir.datacapture.views.factories.DropDownAnswerOption + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun MultiAutoCompleteTextItem( + modifier: Modifier, + enabled: Boolean, + labelText: AnnotatedString? = null, + supportingText: String? = null, + isError: Boolean = false, + selectedOptions: List = emptyList(), + options: List, + onNewOptionSelected: (DropDownAnswerOption) -> Unit, + onOptionDeselected: (DropDownAnswerOption) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + var autoCompleteText by remember(options) { mutableStateOf(TextFieldValue("")) } + val filteredOptions = + remember(options, autoCompleteText) { + options.filter { it.answerOptionString.contains(autoCompleteText.text, true) } + } + + // Track the height of the chip container to add padding to text field + var chipContainerHeight by remember { mutableIntStateOf(0) } + val density = LocalDensity.current + val chipMargin = dimensionResource(R.dimen.auto_complete_chip_margin) + val chipMarginBottom = dimensionResource(R.dimen.auto_complete_chip_margin_bottom) + + val interactionSource = remember { MutableInteractionSource() } + val colors = OutlinedTextFieldDefaults.colors() + val contentPadding = + remember(chipContainerHeight, selectedOptions.size) { + PaddingValues( + start = 16.dp, + top = + if (selectedOptions.isNotEmpty()) { + with(density) { chipContainerHeight.toDp() } + 16.dp + } else { + 16.dp + }, + end = 16.dp, + bottom = 16.dp, + ) + } + + ExposedDropdownMenuBox( + modifier = modifier, + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + Box { + // Text field fills the parent and has content padding for chips + BasicTextField( + value = autoCompleteText, + onValueChange = { + autoCompleteText = it + if (!expanded && autoCompleteText.text.isNotBlank()) expanded = true + }, + modifier = + Modifier.fillMaxWidth() + .testTag(MULTI_AUTO_COMPLETE_TEXT_FIELD_TAG) + .semantics { if (isError) error(supportingText ?: "") } + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable, enabled), + enabled = enabled, + textStyle = TextStyle.Default, + cursorBrush = SolidColor(colors.cursorColor), + interactionSource = interactionSource, + decorationBox = { innerTextField -> + OutlinedTextFieldDefaults.DecorationBox( + value = autoCompleteText.text, + innerTextField = innerTextField, + enabled = enabled, + singleLine = false, + visualTransformation = VisualTransformation.None, + interactionSource = interactionSource, + isError = isError, + label = labelText?.let { { Text(it) } }, + supportingText = supportingText?.let { { Text(it) } }, + colors = colors, + contentPadding = contentPadding, + container = { + OutlinedTextFieldDefaults.Container( + enabled = enabled, + isError = isError, + interactionSource = interactionSource, + colors = colors, + ) + }, + ) + }, + ) + + // Chips overlay at the top of the text field + if (selectedOptions.isNotEmpty()) { + FlowRow( + modifier = + Modifier.fillMaxWidth() + .padding(chipMargin) + .padding(bottom = chipMarginBottom) + .onSizeChanged { size -> chipContainerHeight = size.height }, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + selectedOptions.forEach { + InputChip( + selected = false, + modifier = Modifier.testTag(MULTI_AUTO_COMPLETE_INPUT_CHIP_TAG), + enabled = enabled, + onClick = { onOptionDeselected(it) }, + label = { Text(it.answerOptionAnnotatedString()) }, + trailingIcon = { + Icon( + painterResource(R.drawable.ic_clear), + contentDescription = "Remove ${it.answerOptionString}", + ) + }, + ) + } + } + } + } + + if (filteredOptions.isNotEmpty()) { + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + filteredOptions.forEach { option -> + DropDownAnswerMenuItem(enabled, option) { + autoCompleteText = TextFieldValue("") // Reset autoComplete text to empty + onNewOptionSelected(option) + expanded = false + } + } + } + } + } +} + +const val MULTI_AUTO_COMPLETE_TEXT_FIELD_TAG = "multi_auto_complete_text_field" +const val MULTI_AUTO_COMPLETE_INPUT_CHIP_TAG = "multi_auto_complete_input_chip" diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/AutoCompleteViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/AutoCompleteViewHolderFactory.kt index e47e32198e..f60e07e2e3 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/AutoCompleteViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/AutoCompleteViewHolderFactory.kt @@ -16,233 +16,152 @@ package com.google.android.fhir.datacapture.views.factories -import android.view.View -import android.widget.AdapterView -import android.widget.ArrayAdapter -import android.widget.TextView -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.children -import androidx.core.view.get -import androidx.core.view.isEmpty -import androidx.lifecycle.lifecycleScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.extensions.displayString import com.google.android.fhir.datacapture.extensions.identifierString -import com.google.android.fhir.datacapture.extensions.tryUnwrapContext +import com.google.android.fhir.datacapture.extensions.itemMedia import com.google.android.fhir.datacapture.validation.Invalid -import com.google.android.fhir.datacapture.validation.NotValidated -import com.google.android.fhir.datacapture.validation.Valid -import com.google.android.fhir.datacapture.validation.ValidationResult -import com.google.android.fhir.datacapture.views.HeaderView import com.google.android.fhir.datacapture.views.QuestionnaireViewItem -import com.google.android.material.chip.Chip -import com.google.android.material.chip.ChipGroup -import com.google.android.material.textfield.MaterialAutoCompleteTextView -import com.google.android.material.textfield.TextInputLayout +import com.google.android.fhir.datacapture.views.compose.Header +import com.google.android.fhir.datacapture.views.compose.MediaItem +import com.google.android.fhir.datacapture.views.compose.MultiAutoCompleteTextItem +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.QuestionnaireResponse -internal object AutoCompleteViewHolderFactory : - QuestionnaireItemAndroidViewHolderFactory(R.layout.edit_text_auto_complete_view) { +internal object AutoCompleteViewHolderFactory : QuestionnaireItemComposeViewHolderFactory { override fun getQuestionnaireItemViewHolderDelegate() = - object : QuestionnaireItemAndroidViewHolderDelegate { - private lateinit var context: AppCompatActivity - private lateinit var header: HeaderView - private lateinit var autoCompleteTextView: MaterialAutoCompleteTextView - private lateinit var chipContainer: ChipGroup - private lateinit var textInputLayout: TextInputLayout - private val canHaveMultipleAnswers - get() = questionnaireViewItem.questionnaireItem.repeats - - override lateinit var questionnaireViewItem: QuestionnaireViewItem - private lateinit var errorTextView: TextView - - override fun init(itemView: View) { - context = itemView.context.tryUnwrapContext()!! - header = itemView.findViewById(R.id.header) - autoCompleteTextView = itemView.findViewById(R.id.autoCompleteTextView) - chipContainer = itemView.findViewById(R.id.chipContainer) - textInputLayout = itemView.findViewById(R.id.text_input_layout) - errorTextView = itemView.findViewById(R.id.error) - autoCompleteTextView.onItemClickListener = - AdapterView.OnItemClickListener { _, _, position, _ -> - val answer = - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { - value = - questionnaireViewItem.enabledAnswerOptions - .first { - it.value.identifierString(header.context) == - (autoCompleteTextView.adapter.getItem(position) - as AutoCompleteViewAnswerOption) - .answerId - } - .valueCoding - } - - onAnswerSelected(answer) - autoCompleteTextView.setText("") + object : QuestionnaireItemComposeViewHolderDelegate { + + @Composable + override fun Content(questionnaireViewItem: QuestionnaireViewItem) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope { Dispatchers.Main } + val canHaveMultipleAnswers = + remember(questionnaireViewItem.questionnaireItem) { + questionnaireViewItem.questionnaireItem.repeats } - } - - override fun bind(questionnaireViewItem: QuestionnaireViewItem) { - header.bind(questionnaireViewItem, showRequiredOrOptionalText = true) - val answerOptionValues = - questionnaireViewItem.enabledAnswerOptions.map { - AutoCompleteViewAnswerOption( - answerId = it.value.identifierString(header.context), - answerDisplay = it.value.displayString(header.context), + val enabledAnswerOptions = + remember(questionnaireViewItem.enabledAnswerOptions) { + questionnaireViewItem.enabledAnswerOptions.map { + DropDownAnswerOption( + answerId = it.value.identifierString(context), + answerOptionString = it.value.displayString(context), + ) + } + } + var selectedAnswerOptions by + remember(questionnaireViewItem.answers) { + mutableStateOf( + questionnaireViewItem.answers.map { + DropDownAnswerOption( + answerId = it.value.identifierString(context), + answerOptionString = it.value.displayString(context), + ) + }, ) } - val adapter = - ArrayAdapter( - header.context, - R.layout.drop_down_list_item, - R.id.answer_option_textview, - answerOptionValues, - ) - autoCompleteTextView.setAdapter(adapter) - // Remove chips if any from the last bindView call on this VH. - chipContainer.removeAllViews() - presetValuesIfAny() - - displayValidationResult(questionnaireViewItem.validationResult) - } - - override fun setReadOnly(isReadOnly: Boolean) { - for (i in 0 until chipContainer.childCount) { - val view = chipContainer.getChildAt(i) - view.isEnabled = !isReadOnly - if (view is Chip && isReadOnly) { - view.setOnCloseIconClickListener(null) + val errorTextMessage = + remember(questionnaireViewItem.validationResult) { + (questionnaireViewItem.validationResult as? Invalid) + ?.getSingleStringValidationMessage() + ?.takeIf { it.isNotBlank() } } - } - textInputLayout.isEnabled = !isReadOnly - } - - private fun presetValuesIfAny() { - questionnaireViewItem.answers.map { answer -> addNewChipIfNotPresent(answer) } - } - - private fun onAnswerSelected( - answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, - ) { - if (canHaveMultipleAnswers) { - handleSelectionWhenQuestionCanHaveMultipleAnswers(answer) - } else { - handleSelectionWhenQuestionCanHaveSingleAnswer(answer) - } - } - - /** - * Adds a new chip if it not already present in [chipContainer].It returns [true] if a new - * Chip is added and [false] if the Chip is already present for the selected answer. The later - * will happen if the user selects an already selected answer. - */ - private fun addNewChipIfNotPresent( - answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, - ): Boolean { - if (chipIsAlreadyPresent(answer)) return false - - val chip = Chip(chipContainer.context, null, R.attr.questionnaireChipStyle) - chip.id = View.generateViewId() - chip.text = answer.valueCoding.displayOrCode - chip.isCloseIconVisible = true - chip.isClickable = true - chip.isCheckable = false - chip.tag = answer - chip.setOnCloseIconClickListener { - chipContainer.removeView(chip) - onChipRemoved(chip) - } - - chipContainer.addView(chip) - return true - } - - private fun chipIsAlreadyPresent( - answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, - ): Boolean { - return chipContainer.children.any { chip -> - (chip.tag as QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent) - .value - .equalsDeep(answer.value) - } - } - - private fun handleSelectionWhenQuestionCanHaveSingleAnswer( - answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, - ) { - if (chipContainer.isEmpty()) { - addNewChipIfNotPresent(answer) - } else { - (chipContainer[0] as Chip).apply { - text = answer.valueCoding.displayOrCode - tag = answer + val isReadOnly = + remember(questionnaireViewItem.questionnaireItem) { + questionnaireViewItem.questionnaireItem.readOnly } - } - context.lifecycleScope.launch { questionnaireViewItem.setAnswer(answer) } - } - - private fun handleSelectionWhenQuestionCanHaveMultipleAnswers( - answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, - ) { - val answerNotPresent = - questionnaireViewItem.answers.none { it.value.equalsDeep(answer.value) } - if (answerNotPresent) { - addNewChipIfNotPresent(answer) - context.lifecycleScope.launch { questionnaireViewItem.addAnswer(answer) } - } - } - - private fun onChipRemoved(chip: Chip) { - context.lifecycleScope.launch { - if (canHaveMultipleAnswers) { - (chip.tag as QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent).let { - questionnaireViewItem.removeAnswer(it) - } - } else { - questionnaireViewItem.clearAnswer() - } - } - } - - private fun displayValidationResult(validationResult: ValidationResult) { - // https://github.com/material-components/material-components-android/issues/1435 - // Because of the above issue, we use separate error textview. But we still use - // textInputLayout to show the error icon and the box color. - when (validationResult) { - is NotValidated, - Valid, -> { - errorTextView.visibility = View.GONE - textInputLayout.error = null - } - is Invalid -> { - errorTextView.text = validationResult.getSingleStringValidationMessage() - errorTextView.visibility = View.VISIBLE - textInputLayout.error = " " // non empty text - } + Column( + modifier = + Modifier.fillMaxWidth() + .padding( + horizontal = dimensionResource(R.dimen.item_margin_horizontal), + vertical = dimensionResource(R.dimen.item_margin_vertical), + ), + ) { + Header(questionnaireViewItem, showRequiredOrOptionalText = true) + questionnaireViewItem.questionnaireItem.itemMedia?.let { MediaItem(it) } + + // TODO: Set text gravity using Modifier + // .align(Alignment.BottomCenter) + MultiAutoCompleteTextItem( + modifier = Modifier.fillMaxWidth(), + enabled = !isReadOnly, + supportingText = errorTextMessage, + isError = errorTextMessage.isNullOrBlank().not(), + options = enabledAnswerOptions, + selectedOptions = selectedAnswerOptions, + onNewOptionSelected = { answerOption -> + selectedAnswerOptions = + if (canHaveMultipleAnswers) { + if (answerOption in selectedAnswerOptions) { + selectedAnswerOptions + } else { + selectedAnswerOptions + answerOption + } + } else { + listOf(answerOption) + } + + val questionnaireResponseAnswer = + questionnaireViewItem.enabledAnswerOptions + .first { it.value.identifierString(context) == answerOption.answerId } + .valueCoding + .let { + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = it + } + } + + val answerNotPresent = + questionnaireViewItem.answers.none { + it.value.equalsDeep(questionnaireResponseAnswer.value) + } + if (answerNotPresent) { + coroutineScope.launch { + if (canHaveMultipleAnswers) { + questionnaireViewItem.addAnswer(questionnaireResponseAnswer) + } else { + questionnaireViewItem.setAnswer(questionnaireResponseAnswer) + } + } + } + }, + onOptionDeselected = { option -> + selectedAnswerOptions = selectedAnswerOptions.filterNot { it == option } + + val answerOptionCoding = + questionnaireViewItem.enabledAnswerOptions + .first { it.value.identifierString(context) == option.answerId } + .valueCoding + coroutineScope.launch { + if (canHaveMultipleAnswers) { + questionnaireViewItem.removeAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = answerOptionCoding + }, + ) + } else { + questionnaireViewItem.clearAnswer() + } + } + }, + ) } } - - private val Coding.displayOrCode: String - get() = - if (display.isNullOrBlank()) { - code - } else { - display - } } } - -/** - * An answer option that would show up as a dropdown item in an [AutoCompleteViewHolderFactory] - * textview - */ -internal data class AutoCompleteViewAnswerOption(val answerId: String, val answerDisplay: String) { - override fun toString(): String { - return this.answerDisplay - } -} diff --git a/datacapture/src/main/res/layout/edit_text_auto_complete_view.xml b/datacapture/src/main/res/layout/edit_text_auto_complete_view.xml deleted file mode 100644 index 813d032317..0000000000 --- a/datacapture/src/main/res/layout/edit_text_auto_complete_view.xml +++ /dev/null @@ -1,82 +0,0 @@ - - - - - - - - - - - - - - - - - - - - -