diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DialogMultiSelectViewHolderFactoryTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DialogMultiSelectViewHolderFactoryTest.kt new file mode 100644 index 0000000000..58fef0c84a --- /dev/null +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DialogMultiSelectViewHolderFactoryTest.kt @@ -0,0 +1,1040 @@ +/* + * 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. + * 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.test.views + +import android.widget.FrameLayout +import android.widget.TextView +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.assertIsNotEnabled +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.text.AnnotatedString +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.DisplayItemControlType +import com.google.android.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_SYSTEM +import com.google.android.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_URL +import com.google.android.fhir.datacapture.extensions.EXTENSION_OPTION_EXCLUSIVE_URL +import com.google.android.fhir.datacapture.extensions.ItemControlTypes +import com.google.android.fhir.datacapture.test.TestActivity +import com.google.android.fhir.datacapture.test.utilities.assertQuestionnaireResponseAtIndex +import com.google.android.fhir.datacapture.validation.Invalid +import com.google.android.fhir.datacapture.validation.NotValidated +import com.google.android.fhir.datacapture.views.QuestionTextConfiguration +import com.google.android.fhir.datacapture.views.QuestionnaireViewItem +import com.google.android.fhir.datacapture.views.compose.ERROR_TEXT_AT_HEADER_TEST_TAG +import com.google.android.fhir.datacapture.views.compose.OPTION_CHOICE_LIST_TAG +import com.google.android.fhir.datacapture.views.compose.OPTION_CHOICE_TAG +import com.google.android.fhir.datacapture.views.compose.OTHER_OPTION_TEXT_FIELD_TAG +import com.google.android.fhir.datacapture.views.factories.DialogSelectViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.MULTI_SELECT_TEXT_FIELD_TAG +import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder +import com.google.common.truth.Truth.assertThat +import org.hl7.fhir.r4.model.BooleanType +import org.hl7.fhir.r4.model.CodeableConcept +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.Extension +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 + +@RunWith(AndroidJUnit4::class) +class DialogMultiSelectViewHolderFactoryTest { + @get:Rule + var activityScenarioRule: ActivityScenarioRule = + ActivityScenarioRule(TestActivity::class.java) + + @get:Rule val composeTestRule = createEmptyComposeRule() + + private lateinit var viewHolder: QuestionnaireItemViewHolder + + @Before + fun setup() { + activityScenarioRule.scenario.onActivity { activity -> + viewHolder = DialogSelectViewHolderFactory.create(FrameLayout(activity)) + activity.setContentView(viewHolder.itemView) + } + + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + } + + @Test + fun multipleChoice_selectMultiple_clickSave_shouldSaveMultipleOptions() { + var answerHolder: List? = null + val questionnaireViewItem = + QuestionnaireViewItem( + answerOptions(true, "Coding 1", "Coding 2", "Coding 3", "Coding 4", "Coding 5"), + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, + ) + + viewHolder.bind(questionnaireViewItem) + + // Click to open the dialog + composeTestRule.onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + + // Select options in the dialog + composeTestRule.onNodeWithText("Coding 1").performClick() + composeTestRule.onNodeWithText("Coding 3").performClick() + composeTestRule.onNodeWithText("Coding 5").performClick() + composeTestRule + .onNodeWithText(viewHolder.itemView.context.getString(R.string.save)) + .performClick() + + composeTestRule + .onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG) + .assertTextEquals("Coding 1, Coding 3, Coding 5") + .assertIsDisplayed() + assertQuestionnaireResponseAtIndex(answerHolder!!, "Coding 1", "Coding 3", "Coding 5") + } + + @Test + fun multipleChoice_selectMultiple_selectExclusive_clickSave_shouldSaveOnlyExclusiveOption() { + var answerHolder: List? = null + val questionnaireViewItem = + QuestionnaireViewItem( + answerOptions(true, "Coding 1", "Coding 2", "Coding 3") + .addAnswerOption( + Questionnaire.QuestionnaireItemAnswerOptionComponent().apply { + value = Coding().apply { display = "Coding Exclusive" } + extension = listOf(Extension(EXTENSION_OPTION_EXCLUSIVE_URL, BooleanType(true))) + }, + ), + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, + ) + + viewHolder.bind(questionnaireViewItem) + + composeTestRule.onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + + composeTestRule.onNodeWithText("Coding 1").performClick() + composeTestRule.onNodeWithText("Coding 3").performClick() + composeTestRule.onNodeWithText("Coding Exclusive").performClick() + composeTestRule + .onNodeWithText(viewHolder.itemView.context.getString(R.string.save)) + .performClick() + + composeTestRule + .onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG) + .assertTextEquals("Coding Exclusive") + .assertIsDisplayed() + assertQuestionnaireResponseAtIndex(answerHolder!!, "Coding Exclusive") + } + + @Test + fun multipleChoice_selectExclusive_selectMultiple_clickSave_shouldSaveWithoutExclusiveOption() { + var answerHolder: List? = null + val questionnaireViewItem = + QuestionnaireViewItem( + answerOptions(true, "Coding 1", "Coding 2", "Coding 3") + .addAnswerOption( + Questionnaire.QuestionnaireItemAnswerOptionComponent().apply { + value = Coding().apply { display = "Coding Exclusive" } + extension = listOf(Extension(EXTENSION_OPTION_EXCLUSIVE_URL, BooleanType(true))) + }, + ), + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, + ) + + viewHolder.bind(questionnaireViewItem) + + composeTestRule.onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + + composeTestRule.onNodeWithText("Coding Exclusive").performClick() + composeTestRule.onNodeWithText("Coding 1").performClick() + composeTestRule.onNodeWithText("Coding 3").performClick() + composeTestRule + .onNodeWithText(viewHolder.itemView.context.getString(R.string.save)) + .performClick() + + composeTestRule + .onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG) + .assertTextEquals("Coding 1, Coding 3") + .assertIsDisplayed() + assertQuestionnaireResponseAtIndex(answerHolder!!, "Coding 1", "Coding 3") + } + + @Test + fun multipleChoice_multipleOptionExclusive_selectMultiple_selectExclusive1_selectExclusive2_clickSave_shouldSaveOnlyLastSelectedExclusiveOption() { + var answerHolder: List? = null + val questionnaireViewItem = + QuestionnaireViewItem( + answerOptions(true, "Coding 1", "Coding 2", "Coding 3") + .addAnswerOption( + Questionnaire.QuestionnaireItemAnswerOptionComponent().apply { + value = Coding().apply { display = "Coding Exclusive 1" } + extension = listOf(Extension(EXTENSION_OPTION_EXCLUSIVE_URL, BooleanType(true))) + }, + ) + .addAnswerOption( + Questionnaire.QuestionnaireItemAnswerOptionComponent().apply { + value = Coding().apply { display = "Coding Exclusive 2" } + extension = listOf(Extension(EXTENSION_OPTION_EXCLUSIVE_URL, BooleanType(true))) + }, + ), + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, + ) + + viewHolder.bind(questionnaireViewItem) + + composeTestRule.onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + + composeTestRule.onNodeWithText("Coding 1").performClick() + composeTestRule.onNodeWithText("Coding 3").performClick() + composeTestRule.onNodeWithText("Coding Exclusive 1").performClick() + composeTestRule.onNodeWithText("Coding Exclusive 2").performClick() + composeTestRule + .onNodeWithText(viewHolder.itemView.context.getString(R.string.save)) + .performClick() + + composeTestRule + .onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG) + .assertTextEquals("Coding Exclusive 2") + .assertIsDisplayed() + assertQuestionnaireResponseAtIndex(answerHolder!!, "Coding Exclusive 2") + } + + @Test + fun multipleChoice_multipleOptionExclusive_selectExclusive1_selectExclusive2_selectMultiple_clickSave_shouldSaveWithoutAnyExclusiveOption() { + var answerHolder: List? = null + val questionnaireViewItem = + QuestionnaireViewItem( + answerOptions(true, "Coding 1", "Coding 2", "Coding 3") + .addAnswerOption( + Questionnaire.QuestionnaireItemAnswerOptionComponent().apply { + value = Coding().apply { display = "Coding Exclusive 1" } + extension = listOf(Extension(EXTENSION_OPTION_EXCLUSIVE_URL, BooleanType(true))) + }, + ) + .addAnswerOption( + Questionnaire.QuestionnaireItemAnswerOptionComponent().apply { + value = Coding().apply { display = "Coding Exclusive 2" } + extension = listOf(Extension(EXTENSION_OPTION_EXCLUSIVE_URL, BooleanType(true))) + }, + ), + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, + ) + + viewHolder.bind(questionnaireViewItem) + + composeTestRule.onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + + composeTestRule.onNodeWithText("Coding Exclusive 1").performClick() + composeTestRule.onNodeWithText("Coding Exclusive 2").performClick() + composeTestRule.onNodeWithText("Coding 1").performClick() + composeTestRule.onNodeWithText("Coding 3").performClick() + composeTestRule + .onNodeWithText(viewHolder.itemView.context.getString(R.string.save)) + .performClick() + + composeTestRule + .onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG) + .assertTextEquals("Coding 1, Coding 3") + .assertIsDisplayed() + assertQuestionnaireResponseAtIndex(answerHolder!!, "Coding 1", "Coding 3") + } + + @Test + fun multipleChoice_SelectNothing_clickSave_shouldSaveNothing() { + val questionnaireViewItem = + QuestionnaireViewItem( + answerOptions(true, "Coding 1", "Coding 2", "Coding 3", "Coding 4", "Coding 5"), + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + viewHolder.bind(questionnaireViewItem) + + composeTestRule.onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + + composeTestRule + .onNodeWithText(viewHolder.itemView.context.getString(R.string.save)) + .performClick() + + // When nothing is selected, the field should be empty + composeTestRule + .onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG) + .assertTextEquals("") + .assertIsDisplayed() + assertThat(questionnaireViewItem.answers).isEmpty() + } + + @Test + fun multipleChoice_selectMultiple_clickCancel_shouldSaveNothing() { + val questionnaireViewItem = + QuestionnaireViewItem( + answerOptions(true, "Coding 1", "Coding 2", "Coding 3", "Coding 4", "Coding 5"), + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + viewHolder.bind(questionnaireViewItem) + + composeTestRule.onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + + composeTestRule.onNodeWithText("Coding 3").performClick() + composeTestRule.onNodeWithText("Coding 1").performClick() + composeTestRule.onNodeWithText("Cancel").performClick() + + // When cancelled, nothing should be saved + composeTestRule + .onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG) + .assertTextEquals("") + .assertIsDisplayed() + assertThat(questionnaireViewItem.answers).isEmpty() + } + + @Test + fun shouldSelectSingleOptionOnChangeInOptionFromDropDown() { + var answerHolder: List? = null + val questionnaireViewItem = + QuestionnaireViewItem( + answerOptions(false, "Coding 1", "Coding 2", "Coding 3"), + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, + ) + + viewHolder.bind(questionnaireViewItem) + + composeTestRule.onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + + composeTestRule.onNodeWithText("Coding 2").performClick() + composeTestRule.onNodeWithText("Coding 1").performClick() + composeTestRule + .onNodeWithText(viewHolder.itemView.context.getString(R.string.save)) + .performClick() + + composeTestRule + .onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG) + .assertTextEquals("Coding 1") + .assertIsDisplayed() + assertQuestionnaireResponseAtIndex(answerHolder!!, "Coding 1") + } + + @Test + fun singleOption_select_clickSave_shouldSaveSingleOption() { + var answerHolder: List? = null + val questionnaireViewItem = + QuestionnaireViewItem( + answerOptions(false, "Coding 1", "Coding 2", "Coding 3", "Coding 4", "Coding 5"), + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, + ) + + viewHolder.bind(questionnaireViewItem) + + composeTestRule.onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + + composeTestRule.onNodeWithText("Coding 2").performClick() + composeTestRule + .onNodeWithText(viewHolder.itemView.context.getString(R.string.save)) + .performClick() + + composeTestRule.onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).assertTextEquals("Coding 2") + assertQuestionnaireResponseAtIndex(answerHolder!!, "Coding 2") + } + + @Test + fun singleOption_selectNothing_clickSave_shouldSaveNothing() { + val questionnaireViewItem = + QuestionnaireViewItem( + answerOptions(false, "Coding 1", "Coding 2", "Coding 3", "Coding 4", "Coding 5"), + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + viewHolder.bind(questionnaireViewItem) + + composeTestRule.onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + composeTestRule + .onNodeWithText(viewHolder.itemView.context.getString(R.string.save)) + .performClick() + + // When nothing is selected, the field should be empty + composeTestRule + .onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG) + .assertTextEquals("") + .assertIsDisplayed() + assertThat(questionnaireViewItem.answers).isEmpty() + } + + @Test + fun bindView_setHintText() { + val hintItem = + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "1.1" + text = "Select code" + type = Questionnaire.QuestionnaireItemType.DISPLAY + addExtension( + Extension() + .setUrl(EXTENSION_ITEM_CONTROL_URL) + .setValue( + CodeableConcept() + .addCoding( + Coding() + .setCode(DisplayItemControlType.FLYOVER.extensionCode) + .setSystem(EXTENSION_ITEM_CONTROL_SYSTEM), + ), + ), + ) + } + val questionnaireViewItem = + QuestionnaireViewItem( + answerOptions(false, "Coding 1", "Coding 2", "Coding 3", "Coding 4", "Coding 5") + .addItem(hintItem), + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + enabledDisplayItems = listOf(hintItem), + ) + viewHolder.bind(questionnaireViewItem) + + composeTestRule.onNodeWithText("Select code").assertIsDisplayed() + } + + @Test + fun singleOption_select_clickCancel_shouldSaveNothing() { + val questionnaireViewItem = + QuestionnaireViewItem( + answerOptions(false, "Coding 1", "Coding 2", "Coding 3", "Coding 4", "Coding 5"), + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + viewHolder.bind(questionnaireViewItem) + + composeTestRule.onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + + composeTestRule.onNodeWithText("Coding 2").performClick() + composeTestRule.onNodeWithText("Cancel").performClick() + composeTestRule.waitForIdle() + + // When cancelled, nothing should be saved + assertThat(questionnaireViewItem.answers).isEmpty() + } + + @Test + fun selectOther_shouldScrollDownToShowAddAnotherAnswer() { + val questionnaireItem = + answerOptions( + true, + "Coding 1", + "Coding 2", + "Coding 3", + "Coding 4", + "Coding 5", + "Coding 6", + "Coding 7", + "Coding 8", + ) + questionnaireItem.addExtension(openChoiceType) + val questionnaireViewItem = + QuestionnaireViewItem( + questionnaireItem, + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + viewHolder.bind(questionnaireViewItem) + + composeTestRule.onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + + val context = viewHolder.itemView.context + // Select "Other" option + val otherText = context.getString(R.string.open_choice_other) + composeTestRule.onNodeWithTag(OPTION_CHOICE_LIST_TAG).performScrollToNode(hasText(otherText)) + composeTestRule.onNodeWithText(otherText).performClick() + + // "Add Another" button should be displayed in multi-select mode + composeTestRule + .onNodeWithText(context.getString(R.string.open_choice_other_add_another)) + .assertIsDisplayed() + } + + @Test + fun unselectOther_shouldHideAddAnotherAnswer() { + val questionnaireItem = + answerOptions( + true, + "Coding 1", + "Coding 2", + "Coding 3", + "Coding 4", + "Coding 5", + "Coding 6", + "Coding 7", + "Coding 8", + ) + questionnaireItem.addExtension(openChoiceType) + val questionnaireViewItem = + QuestionnaireViewItem( + questionnaireItem, + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + viewHolder.bind(questionnaireViewItem) + + composeTestRule.onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + + val context = viewHolder.itemView.context + // Select and then unselect "Other" option + val otherText = context.getString(R.string.open_choice_other) + composeTestRule.onNodeWithTag(OPTION_CHOICE_LIST_TAG).performScrollToNode(hasText(otherText)) + composeTestRule.onNodeWithText(otherText).performClick() + composeTestRule.onNodeWithText(otherText).performClick() + + // "Add Another" button should not be displayed when "Other" is unselected + composeTestRule + .onNodeWithText(context.getString(R.string.open_choice_other_add_another)) + .assertDoesNotExist() + } + + @Test + fun clickAddAnotherAnswer_shouldScrollDownToShowAddAnotherAnswer() { + val questionnaireItem = + answerOptions( + true, + "Coding 1", + "Coding 2", + "Coding 3", + "Coding 4", + "Coding 5", + "Coding 6", + "Coding 7", + "Coding 8", + ) + questionnaireItem.addExtension(openChoiceType) + val questionnaireViewItem = + QuestionnaireViewItem( + questionnaireItem, + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + viewHolder.bind(questionnaireViewItem) + + composeTestRule.onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + + val context = viewHolder.itemView.context + // Select "Other" option + val otherText = context.getString(R.string.open_choice_other) + composeTestRule.onNodeWithTag(OPTION_CHOICE_LIST_TAG).performScrollToNode(hasText(otherText)) + composeTestRule.onNodeWithText(otherText).performClick() + + // Click "Add Another" button + val addAnotherText = context.getString(R.string.open_choice_other_add_another) + composeTestRule + .onNodeWithTag(OPTION_CHOICE_LIST_TAG) + .performScrollToNode(hasText(addAnotherText)) + composeTestRule.onNodeWithText(addAnotherText).performClick() + + // "Add Another" button should still be displayed after clicking + composeTestRule.onNodeWithText(addAnotherText).assertIsDisplayed() + } + + @Test + fun selectOther_selectExclusive_shouldHideAddAnotherAnswer() { + val questionnaireItem = + answerOptions( + true, + "Coding 1", + "Coding 2", + "Coding 3", + "Coding 4", + "Coding 5", + "Coding 6", + "Coding 7", + "Coding 8", + ) + .addAnswerOption( + Questionnaire.QuestionnaireItemAnswerOptionComponent().apply { + value = Coding().apply { display = "Coding Exclusive" } + extension = listOf(Extension(EXTENSION_OPTION_EXCLUSIVE_URL, BooleanType(true))) + }, + ) + + questionnaireItem.addExtension(openChoiceType) + val questionnaireViewItem = + QuestionnaireViewItem( + questionnaireItem, + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + viewHolder.bind(questionnaireViewItem) + + composeTestRule.onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + + val context = viewHolder.itemView.context + // Select "Other" option + val otherText = context.getString(R.string.open_choice_other) + composeTestRule.onNodeWithTag(OPTION_CHOICE_LIST_TAG).performScrollToNode(hasText(otherText)) + composeTestRule.onNodeWithText(otherText).performClick() + + // Select exclusive option + composeTestRule.onNodeWithText("Coding Exclusive").performClick() + + // "Add Another" button should not be displayed when exclusive option is selected + composeTestRule + .onNodeWithText(context.getString(R.string.open_choice_other_add_another)) + .assertDoesNotExist() + } + + @Test + fun selectOther_clickAddAnotherAnswer_selectExclusive_shouldHideAddAnotherAnswerWithEditText() { + val questionnaireItem = + answerOptions( + true, + "Coding 1", + "Coding 2", + "Coding 3", + "Coding 4", + "Coding 5", + "Coding 6", + "Coding 7", + "Coding 8", + ) + .addAnswerOption( + Questionnaire.QuestionnaireItemAnswerOptionComponent().apply { + value = Coding().apply { display = "Coding Exclusive" } + extension = listOf(Extension(EXTENSION_OPTION_EXCLUSIVE_URL, BooleanType(true))) + }, + ) + + questionnaireItem.addExtension(openChoiceType) + val questionnaireViewItem = + QuestionnaireViewItem( + questionnaireItem, + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + viewHolder.bind(questionnaireViewItem) + + composeTestRule.onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + + val context = viewHolder.itemView.context + // Select "Other" option + val otherText = context.getString(R.string.open_choice_other) + composeTestRule.onNodeWithTag(OPTION_CHOICE_LIST_TAG).performScrollToNode(hasText(otherText)) + composeTestRule.onNodeWithText(otherText).performClick() + + // Click "Add Another" button + composeTestRule + .onNodeWithText(context.getString(R.string.open_choice_other_add_another)) + .performClick() + + // Select exclusive option + composeTestRule.onNodeWithText("Coding Exclusive").performClick() + + // "Add Another" button and edit text should not be displayed when exclusive option is selected + composeTestRule.onAllNodes(hasTestTag(OTHER_OPTION_TEXT_FIELD_TAG)).assertCountEquals(0) + composeTestRule + .onNodeWithText(context.getString(R.string.open_choice_other_add_another)) + .assertDoesNotExist() + } + + @Test + fun shouldHideErrorTextviewInHeader() { + val questionnaireItem = answerOptions(true, "Coding 1") + questionnaireItem.addExtension(openChoiceType) + val questionnaireViewItem = + QuestionnaireViewItem( + questionnaireItem, + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + viewHolder.bind(questionnaireViewItem) + composeTestRule.onNodeWithTag(ERROR_TEXT_AT_HEADER_TEST_TAG).assertDoesNotExist() + } + + @Test + fun show_asterisk() { + viewHolder.bind( + QuestionnaireViewItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "1" + text = "Question?" + required = true + }, + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = true), + ), + ) + composeTestRule.waitForIdle() + + assertThat(viewHolder.itemView.findViewById(R.id.question).text.toString()) + .isEqualTo("Question? *") + } + + @Test + fun hide_asterisk() { + viewHolder.bind( + QuestionnaireViewItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "1" + text = "Question?" + required = true + }, + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = false), + ), + ) + composeTestRule.waitForIdle() + + assertThat(viewHolder.itemView.findViewById(R.id.question).text.toString()) + .isEqualTo("Question?") + } + + @Test + fun show_requiredText() { + viewHolder.bind( + QuestionnaireViewItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "1" + required = true + text = "Question?" + }, + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showRequiredText = true), + ), + ) + + // The "Required" text should be displayed in the supporting text of the OutlinedTextField + composeTestRule.onNodeWithText("Required").assertIsDisplayed() + } + + @Test + fun hide_requiredText() { + viewHolder.bind( + QuestionnaireViewItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "1" + required = true + text = "Question?" + }, + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showRequiredText = false), + ), + ) + + // When showRequiredText is false, "Required" text should not be displayed + composeTestRule.onNodeWithText("Required").assertDoesNotExist() + } + + @Test + fun shows_optionalText() { + viewHolder.bind( + QuestionnaireViewItem( + Questionnaire.QuestionnaireItemComponent().apply { linkId = "1" }, + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showOptionalText = true), + ), + ) + + // The "Optional" text should be displayed in the supporting text of the OutlinedTextField + composeTestRule.onNodeWithText("Optional").assertIsDisplayed() + } + + @Test + fun hide_optionalText() { + viewHolder.bind( + QuestionnaireViewItem( + Questionnaire.QuestionnaireItemComponent().apply { linkId = "1" }, + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showOptionalText = false), + ), + ) + + // When showOptionalText is false, "Optional" text should not be displayed + composeTestRule.onNodeWithText("Optional").assertDoesNotExist() + } + + @Test + fun multipleChoice_doNotShowErrorInitially() { + val questionnaireViewItem = + QuestionnaireViewItem( + answerOptions(true, "Coding 1", "Coding 2", "Coding 3", "Coding 4", "Coding 5").apply { + required = true + }, + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, _ -> }, + ) + + viewHolder.bind(questionnaireViewItem) + composeTestRule + .onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.keyNotDefined( + SemanticsProperties.Error, + ), + ) + } + + @Test + fun multipleChoice_unselectSelectedAnswer_showErrorWhenNoAnswerIsSelected() { + val questionnaireViewItem = + QuestionnaireViewItem( + answerOptions(false, "Coding 1", "Coding 2").apply { required = true }, + responseOptions(), + validationResult = Invalid(listOf("Missing answer for required field.")), + answersChangedCallback = { _, _, answers, _ -> }, + ) + + viewHolder.bind(questionnaireViewItem) + + composeTestRule.onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + + composeTestRule.onNode(hasTestTag(OPTION_CHOICE_TAG) and hasText("Coding 2")).performClick() + composeTestRule + .onNodeWithText(viewHolder.itemView.context.getString(R.string.save)) + .performClick() + + composeTestRule + .onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.EditableText, + AnnotatedString("Coding 2"), + ), + ) + .assertIsDisplayed() + + composeTestRule.onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + + composeTestRule.onNode(hasTestTag(OPTION_CHOICE_TAG) and hasText("Coding 2")).performClick() + composeTestRule + .onNodeWithText(viewHolder.itemView.context.getString(R.string.save)) + .performClick() + + composeTestRule + .onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.EditableText, + AnnotatedString(""), + ), + ) + .assertIsDisplayed() + composeTestRule + .onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.Error, + "Missing answer for required field.", + ), + ) + } + + @Test + fun emptyResponseOptions_showNoneSelected() { + viewHolder.bind( + QuestionnaireViewItem( + answerOptions(false, "Coding 1", "Coding 2"), + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + composeTestRule + .onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG) + .assertTextEquals("") + .assertIsDisplayed() + } + + @Test + fun selectedResponseOptions_showSelectedOptions() { + viewHolder.bind( + QuestionnaireViewItem( + answerOptions(false, "Coding 1", "Coding 2", "Coding 3"), + responseOptions( + "Coding 1", + "Coding 3", + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + composeTestRule + .onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG) + .assertTextEquals("Coding 1, Coding 3") + } + + @Test + fun displayValidationResult_error_shouldShowErrorMessage() { + viewHolder.bind( + QuestionnaireViewItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "1" + required = true + }, + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = Invalid(listOf("Missing answer for required field.")), + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + composeTestRule + .onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.Error, + "Missing answer for required field.", + ), + ) + } + + @Test + fun displayValidationResult_noError_shouldShowNoErrorMessage() { + viewHolder.bind( + QuestionnaireViewItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "1" + required = true + addAnswerOption( + Questionnaire.QuestionnaireItemAnswerOptionComponent().apply { + value = Coding().apply { display = "display" } + }, + ) + }, + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = Coding().apply { display = "display" } + }, + ) + }, + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + composeTestRule + .onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.keyNotDefined( + SemanticsProperties.Error, + ), + ) + } + + @Test + fun bind_readOnly_shouldDisableView() { + viewHolder.bind( + QuestionnaireViewItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "1" + readOnly = true + }, + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + composeTestRule.onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).assertIsNotEnabled() + } + + private val openChoiceType = + Extension().apply { + url = EXTENSION_ITEM_CONTROL_URL + setValue( + CodeableConcept() + .addCoding( + Coding() + .setCode(ItemControlTypes.OPEN_CHOICE.extensionCode) + .setDisplay("Open Choice") + .setSystem(EXTENSION_ITEM_CONTROL_SYSTEM), + ), + ) + } + + private fun answerOptions(multiSelect: Boolean, vararg options: String) = + Questionnaire.QuestionnaireItemComponent().apply { + this.repeats = multiSelect + linkId = "1" + options.forEach { option -> + addAnswerOption( + Questionnaire.QuestionnaireItemAnswerOptionComponent().apply { + value = Coding().apply { display = option } + }, + ) + } + } + + private fun responseOptions(vararg responses: String) = + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + responses.forEach { response -> + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = Coding().apply { display = response } + }, + ) + } + } +} diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/QuestionnaireItemDialogMultiSelectViewHolderFactoryEspressoTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/QuestionnaireItemDialogMultiSelectViewHolderFactoryEspressoTest.kt deleted file mode 100644 index 911790cb01..0000000000 --- a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/QuestionnaireItemDialogMultiSelectViewHolderFactoryEspressoTest.kt +++ /dev/null @@ -1,843 +0,0 @@ -/* - * Copyright 2023-2024 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * 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.test.views - -import android.view.View -import android.widget.FrameLayout -import android.widget.TextView -import androidx.recyclerview.widget.RecyclerView -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions.click -import androidx.test.espresso.assertion.ViewAssertions.doesNotExist -import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.contrib.RecyclerViewActions -import androidx.test.espresso.matcher.ViewMatchers -import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.ext.junit.rules.ActivityScenarioRule -import androidx.test.filters.SdkSuppress -import androidx.test.platform.app.InstrumentationRegistry -import com.google.android.fhir.datacapture.R -import com.google.android.fhir.datacapture.extensions.DisplayItemControlType -import com.google.android.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_SYSTEM -import com.google.android.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_URL -import com.google.android.fhir.datacapture.extensions.EXTENSION_OPTION_EXCLUSIVE_URL -import com.google.android.fhir.datacapture.extensions.ItemControlTypes -import com.google.android.fhir.datacapture.test.TestActivity -import com.google.android.fhir.datacapture.test.utilities.assertQuestionnaireResponseAtIndex -import com.google.android.fhir.datacapture.test.utilities.clickOnText -import com.google.android.fhir.datacapture.test.utilities.clickOnTextInDialog -import com.google.android.fhir.datacapture.test.utilities.delayMainThread -import com.google.android.fhir.datacapture.test.utilities.endIconClickInTextInputLayout -import com.google.android.fhir.datacapture.validation.Invalid -import com.google.android.fhir.datacapture.validation.NotValidated -import com.google.android.fhir.datacapture.views.QuestionTextConfiguration -import com.google.android.fhir.datacapture.views.QuestionnaireViewItem -import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemDialogSelectViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder -import com.google.android.material.textfield.TextInputLayout -import com.google.common.truth.StringSubject -import com.google.common.truth.Truth.assertThat -import org.hamcrest.Matchers.not -import org.hl7.fhir.r4.model.BooleanType -import org.hl7.fhir.r4.model.CodeableConcept -import org.hl7.fhir.r4.model.Coding -import org.hl7.fhir.r4.model.Extension -import org.hl7.fhir.r4.model.Questionnaire -import org.hl7.fhir.r4.model.QuestionnaireResponse -import org.junit.Before -import org.junit.Ignore -import org.junit.Rule -import org.junit.Test - -class QuestionnaireItemDialogMultiSelectViewHolderFactoryEspressoTest { - @Rule - @JvmField - var activityScenarioRule: ActivityScenarioRule = - ActivityScenarioRule(TestActivity::class.java) - - private lateinit var parent: FrameLayout - private lateinit var viewHolder: QuestionnaireItemViewHolder - - @Before - fun setup() { - activityScenarioRule.scenario.onActivity { activity -> parent = FrameLayout(activity) } - viewHolder = QuestionnaireItemDialogSelectViewHolderFactory.create(parent) - setTestLayout(viewHolder.itemView) - } - - @Test - fun multipleChoice_selectMultiple_clickSave_shouldSaveMultipleOptions() { - var answerHolder: List? = null - val questionnaireViewItem = - QuestionnaireViewItem( - answerOptions(true, "Coding 1", "Coding 2", "Coding 3", "Coding 4", "Coding 5"), - responseOptions(), - validationResult = NotValidated, - answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, - ) - - runOnUI { viewHolder.bind(questionnaireViewItem) } - - endIconClickInTextInputLayout(R.id.multi_select_summary_holder) - clickOnTextInDialog("Coding 1") - clickOnText("Coding 3") - clickOnText("Coding 5") - clickOnText("Save") - - assertDisplayedText().isEqualTo("Coding 1, Coding 3, Coding 5") - assertQuestionnaireResponseAtIndex(answerHolder!!, "Coding 1", "Coding 3", "Coding 5") - } - - @Test - fun multipleChoice_selectMultiple_selectExclusive_clickSave_shouldSaveOnlyExclusiveOption() { - var answerHolder: List? = null - val questionnaireViewItem = - QuestionnaireViewItem( - answerOptions(true, "Coding 1", "Coding 2", "Coding 3") - .addAnswerOption( - Questionnaire.QuestionnaireItemAnswerOptionComponent().apply { - value = Coding().apply { display = "Coding Exclusive" } - extension = listOf(Extension(EXTENSION_OPTION_EXCLUSIVE_URL, BooleanType(true))) - }, - ), - responseOptions(), - validationResult = NotValidated, - answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, - ) - - runOnUI { viewHolder.bind(questionnaireViewItem) } - - endIconClickInTextInputLayout(R.id.multi_select_summary_holder) - clickOnTextInDialog("Coding 1") - clickOnText("Coding 3") - clickOnText("Coding Exclusive") - clickOnText("Save") - - assertDisplayedText().isEqualTo("Coding Exclusive") - assertQuestionnaireResponseAtIndex(answerHolder!!, "Coding Exclusive") - } - - @Test - fun multipleChoice_selectExclusive_selectMultiple_clickSave_shouldSaveWithoutExclusiveOption() { - var answerHolder: List? = null - val questionnaireViewItem = - QuestionnaireViewItem( - answerOptions(true, "Coding 1", "Coding 2", "Coding 3") - .addAnswerOption( - Questionnaire.QuestionnaireItemAnswerOptionComponent().apply { - value = Coding().apply { display = "Coding Exclusive" } - extension = listOf(Extension(EXTENSION_OPTION_EXCLUSIVE_URL, BooleanType(true))) - }, - ), - responseOptions(), - validationResult = NotValidated, - answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, - ) - - runOnUI { viewHolder.bind(questionnaireViewItem) } - - endIconClickInTextInputLayout(R.id.multi_select_summary_holder) - clickOnTextInDialog("Coding Exclusive") - clickOnText("Coding 1") - clickOnText("Coding 3") - clickOnText("Save") - - assertDisplayedText().isEqualTo("Coding 1, Coding 3") - assertQuestionnaireResponseAtIndex(answerHolder!!, "Coding 1", "Coding 3") - } - - @Test - fun multipleChoice_multipleOptionExclusive_selectMultiple_selectExclusive1_selectExclusive2_clickSave_shouldSaveOnlyLastSelectedExclusiveOption() { - var answerHolder: List? = null - val questionnaireViewItem = - QuestionnaireViewItem( - answerOptions(true, "Coding 1", "Coding 2", "Coding 3") - .addAnswerOption( - Questionnaire.QuestionnaireItemAnswerOptionComponent().apply { - value = Coding().apply { display = "Coding Exclusive 1" } - extension = listOf(Extension(EXTENSION_OPTION_EXCLUSIVE_URL, BooleanType(true))) - }, - ) - .addAnswerOption( - Questionnaire.QuestionnaireItemAnswerOptionComponent().apply { - value = Coding().apply { display = "Coding Exclusive 2" } - extension = listOf(Extension(EXTENSION_OPTION_EXCLUSIVE_URL, BooleanType(true))) - }, - ), - responseOptions(), - validationResult = NotValidated, - answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, - ) - - runOnUI { viewHolder.bind(questionnaireViewItem) } - - endIconClickInTextInputLayout(R.id.multi_select_summary_holder) - clickOnTextInDialog("Coding 1") - clickOnText("Coding 3") - clickOnText("Coding Exclusive 1") - clickOnText("Coding Exclusive 2") - clickOnText("Save") - - assertDisplayedText().isEqualTo("Coding Exclusive 2") - assertQuestionnaireResponseAtIndex(answerHolder!!, "Coding Exclusive 2") - } - - @Test - fun multipleChoice_multipleOptionExclusive_selectExclusive1_selectExclusive2_selectMultiple_clickSave_shouldSaveWithoutAnyExclusiveOption() { - var answerHolder: List? = null - val questionnaireViewItem = - QuestionnaireViewItem( - answerOptions(true, "Coding 1", "Coding 2", "Coding 3") - .addAnswerOption( - Questionnaire.QuestionnaireItemAnswerOptionComponent().apply { - value = Coding().apply { display = "Coding Exclusive 1" } - extension = listOf(Extension(EXTENSION_OPTION_EXCLUSIVE_URL, BooleanType(true))) - }, - ) - .addAnswerOption( - Questionnaire.QuestionnaireItemAnswerOptionComponent().apply { - value = Coding().apply { display = "Coding Exclusive 2" } - extension = listOf(Extension(EXTENSION_OPTION_EXCLUSIVE_URL, BooleanType(true))) - }, - ), - responseOptions(), - validationResult = NotValidated, - answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, - ) - - runOnUI { viewHolder.bind(questionnaireViewItem) } - - endIconClickInTextInputLayout(R.id.multi_select_summary_holder) - clickOnTextInDialog("Coding Exclusive 1") - clickOnTextInDialog("Coding Exclusive 2") - clickOnText("Coding 1") - clickOnText("Coding 3") - clickOnText("Save") - - assertDisplayedText().isEqualTo("Coding 1, Coding 3") - assertQuestionnaireResponseAtIndex(answerHolder!!, "Coding 1", "Coding 3") - } - - @Test - fun multipleChoice_SelectNothing_clickSave_shouldSaveNothing() { - val questionnaireViewItem = - QuestionnaireViewItem( - answerOptions(true, "Coding 1", "Coding 2", "Coding 3", "Coding 4", "Coding 5"), - responseOptions(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ) - - runOnUI { viewHolder.bind(questionnaireViewItem) } - - endIconClickInTextInputLayout(R.id.multi_select_summary_holder) - clickOnTextInDialog("Save") - - assertDisplayedText().isEmpty() - assertThat(questionnaireViewItem.answers).isEmpty() - } - - @Test - fun multipleChoice_selectMultiple_clickCancel_shouldSaveNothing() { - val questionnaireViewItem = - QuestionnaireViewItem( - answerOptions(true, "Coding 1", "Coding 2", "Coding 3", "Coding 4", "Coding 5"), - responseOptions(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ) - - runOnUI { viewHolder.bind(questionnaireViewItem) } - - endIconClickInTextInputLayout(R.id.multi_select_summary_holder) - clickOnTextInDialog("Coding 3") - clickOnText("Coding 1") - clickOnText("Cancel") - - assertDisplayedText().isEmpty() - assertThat(questionnaireViewItem.answers).isEmpty() - } - - @Test - fun shouldSelectSingleOptionOnChangeInOptionFromDropDown() { - var answerHolder: List? = null - val questionnaireViewItem = - QuestionnaireViewItem( - answerOptions(false, "Coding 1", "Coding 2", "Coding 3"), - responseOptions(), - validationResult = NotValidated, - answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, - ) - - runOnUI { viewHolder.bind(questionnaireViewItem) } - - endIconClickInTextInputLayout(R.id.multi_select_summary_holder) - clickOnTextInDialog("Coding 2") - clickOnText("Coding 1") - clickOnText("Save") - - assertDisplayedText().isEqualTo("Coding 1") - assertQuestionnaireResponseAtIndex(answerHolder!!, "Coding 1") - } - - @Test - fun singleOption_select_clickSave_shouldSaveSingleOption() { - var answerHolder: List? = null - val questionnaireViewItem = - QuestionnaireViewItem( - answerOptions(false, "Coding 1", "Coding 2", "Coding 3", "Coding 4", "Coding 5"), - responseOptions(), - validationResult = NotValidated, - answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, - ) - - runOnUI { viewHolder.bind(questionnaireViewItem) } - - endIconClickInTextInputLayout(R.id.multi_select_summary_holder) - clickOnTextInDialog("Coding 2") - clickOnText("Save") - - assertDisplayedText().isEqualTo("Coding 2") - assertQuestionnaireResponseAtIndex(answerHolder!!, "Coding 2") - } - - @Test - fun singleOption_selectNothing_clickSave_shouldSaveNothing() { - val questionnaireViewItem = - QuestionnaireViewItem( - answerOptions(false, "Coding 1", "Coding 2", "Coding 3", "Coding 4", "Coding 5"), - responseOptions(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ) - - runOnUI { viewHolder.bind(questionnaireViewItem) } - - endIconClickInTextInputLayout(R.id.multi_select_summary_holder) - clickOnTextInDialog("Save") - - assertDisplayedText().isEmpty() - assertThat(questionnaireViewItem.answers).isEmpty() - } - - @Test - fun bindView_setHintText() { - val hintItem = - Questionnaire.QuestionnaireItemComponent().apply { - linkId = "1.1" - text = "Select code" - type = Questionnaire.QuestionnaireItemType.DISPLAY - addExtension( - Extension() - .setUrl(EXTENSION_ITEM_CONTROL_URL) - .setValue( - CodeableConcept() - .addCoding( - Coding() - .setCode(DisplayItemControlType.FLYOVER.extensionCode) - .setSystem(EXTENSION_ITEM_CONTROL_SYSTEM), - ), - ), - ) - } - val questionnaireViewItem = - QuestionnaireViewItem( - answerOptions(false, "Coding 1", "Coding 2", "Coding 3", "Coding 4", "Coding 5") - .addItem(hintItem), - responseOptions(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - enabledDisplayItems = listOf(hintItem), - ) - runOnUI { viewHolder.bind(questionnaireViewItem) } - - assertThat( - viewHolder.itemView - .findViewById(R.id.multi_select_summary_holder) - .hint - .toString(), - ) - .isEqualTo("Select code") - } - - @Test - fun singleOption_select_clickCancel_shouldSaveNothing() { - val questionnaireViewItem = - QuestionnaireViewItem( - answerOptions(false, "Coding 1", "Coding 2", "Coding 3", "Coding 4", "Coding 5"), - responseOptions(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ) - - runOnUI { viewHolder.bind(questionnaireViewItem) } - endIconClickInTextInputLayout(R.id.multi_select_summary_holder) - clickOnTextInDialog("Coding 2") - clickOnText("Cancel") - - assertDisplayedText().isEmpty() - assertThat(questionnaireViewItem.answers).isEmpty() - } - - @Test - @Ignore // TODO https://github.com/google/android-fhir/issues/1482 FIXME - fun selectOther_shouldScrollDownToShowAddAnotherAnswer() { - val questionnaireItem = - answerOptions( - true, - "Coding 1", - "Coding 2", - "Coding 3", - "Coding 4", - "Coding 5", - "Coding 6", - "Coding 7", - "Coding 8", - ) - questionnaireItem.addExtension(openChoiceType) - val questionnaireViewItem = - QuestionnaireViewItem( - questionnaireItem, - responseOptions(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ) - - runOnUI { viewHolder.bind(questionnaireViewItem) } - - endIconClickInTextInputLayout(R.id.multi_select_summary_holder) - onView(withId(R.id.recycler_view)) - .perform(RecyclerViewActions.scrollToPosition(8)) - clickOnTextInDialog("Other") - onView(withId(R.id.add_another)).perform(delayMainThread()) - onView(withId(R.id.add_another)).check(matches(isDisplayed())) - } - - @Test - fun unselectOther_shouldHideAddAnotherAnswer() { - val questionnaireItem = - answerOptions( - true, - "Coding 1", - "Coding 2", - "Coding 3", - "Coding 4", - "Coding 5", - "Coding 6", - "Coding 7", - "Coding 8", - ) - questionnaireItem.addExtension(openChoiceType) - val questionnaireViewItem = - QuestionnaireViewItem( - questionnaireItem, - responseOptions(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ) - - runOnUI { viewHolder.bind(questionnaireViewItem) } - - endIconClickInTextInputLayout(R.id.multi_select_summary_holder) - onView(withId(R.id.recycler_view)) - .perform(RecyclerViewActions.scrollToPosition(8)) - clickOnTextInDialog("Other") - clickOnTextInDialog("Other") - onView(ViewMatchers.withId(R.id.add_another)).check(doesNotExist()) - } - - @Test - @SdkSuppress(minSdkVersion = 33) // TODO https://github.com/google/android-fhir/issues/1482 FIXME - fun clickAddAnotherAnswer_shouldScrollDownToShowAddAnotherAnswer() { - val questionnaireItem = - answerOptions( - true, - "Coding 1", - "Coding 2", - "Coding 3", - "Coding 4", - "Coding 5", - "Coding 6", - "Coding 7", - "Coding 8", - ) - questionnaireItem.addExtension(openChoiceType) - val questionnaireViewItem = - QuestionnaireViewItem( - questionnaireItem, - responseOptions(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ) - - runOnUI { viewHolder.bind(questionnaireViewItem) } - - endIconClickInTextInputLayout(R.id.multi_select_summary_holder) - onView(withId(R.id.recycler_view)) - .perform(RecyclerViewActions.scrollToPosition(8)) - clickOnTextInDialog("Other") - onView(withId(R.id.add_another)).perform(click()) - onView(withId(R.id.add_another)).perform(delayMainThread()) - onView(withId(R.id.add_another)).check(matches(isDisplayed())) - } - - @Test - @SdkSuppress(minSdkVersion = 33) - fun selectOther_selectExclusive_shouldHideAddAnotherAnswer() { - val questionnaireItem = - answerOptions( - true, - "Coding 1", - "Coding 2", - "Coding 3", - "Coding 4", - "Coding 5", - "Coding 6", - "Coding 7", - "Coding 8", - ) - .addAnswerOption( - Questionnaire.QuestionnaireItemAnswerOptionComponent().apply { - value = Coding().apply { display = "Coding Exclusive" } - extension = listOf(Extension(EXTENSION_OPTION_EXCLUSIVE_URL, BooleanType(true))) - }, - ) - - questionnaireItem.addExtension(openChoiceType) - val questionnaireViewItem = - QuestionnaireViewItem( - questionnaireItem, - responseOptions(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ) - - runOnUI { viewHolder.bind(questionnaireViewItem) } - - endIconClickInTextInputLayout(R.id.multi_select_summary_holder) - onView(withId(R.id.recycler_view)) - .perform(RecyclerViewActions.scrollToPosition(9)) - clickOnTextInDialog("Other") - clickOnTextInDialog("Coding Exclusive") - onView(withId(R.id.add_another)).check(doesNotExist()) - } - - @Test - @SdkSuppress(minSdkVersion = 33) - fun selectOther_clickAddAnotherAnswer_selectExclusive_shouldHideAddAnotherAnswerWithEditText() { - val questionnaireItem = - answerOptions( - true, - "Coding 1", - "Coding 2", - "Coding 3", - "Coding 4", - "Coding 5", - "Coding 6", - "Coding 7", - "Coding 8", - ) - .addAnswerOption( - Questionnaire.QuestionnaireItemAnswerOptionComponent().apply { - value = Coding().apply { display = "Coding Exclusive" } - extension = listOf(Extension(EXTENSION_OPTION_EXCLUSIVE_URL, BooleanType(true))) - }, - ) - - questionnaireItem.addExtension(openChoiceType) - val questionnaireViewItem = - QuestionnaireViewItem( - questionnaireItem, - responseOptions(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ) - - runOnUI { viewHolder.bind(questionnaireViewItem) } - - endIconClickInTextInputLayout(R.id.multi_select_summary_holder) - onView(withId(R.id.recycler_view)) - .perform(RecyclerViewActions.scrollToPosition(9)) - clickOnTextInDialog("Other") - onView(withId(R.id.add_another)).perform(delayMainThread()) - onView(withId(R.id.add_another)).perform(click()) - clickOnTextInDialog("Coding Exclusive") - onView(withId(R.id.add_another)).check(doesNotExist()) - onView(withId(R.id.edit_text)).check(doesNotExist()) - } - - @Test - fun shouldHideErrorTextviewInHeader() { - val questionnaireItem = answerOptions(true, "Coding 1") - questionnaireItem.addExtension(openChoiceType) - val questionnaireViewItem = - QuestionnaireViewItem( - questionnaireItem, - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ) - - runOnUI { viewHolder.bind(questionnaireViewItem) } - - onView(withId(R.id.error_text_at_header)).check(matches(not(isDisplayed()))) - } - - @Test - fun show_asterisk() { - runOnUI { - viewHolder.bind( - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent().apply { - linkId = "1" - text = "Question?" - required = true - }, - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = true), - ), - ) - - assertThat(viewHolder.itemView.findViewById(R.id.question).text.toString()) - .isEqualTo("Question? *") - } - } - - @Test - fun hide_asterisk() { - runOnUI { - viewHolder.bind( - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent().apply { - linkId = "1" - text = "Question?" - required = true - }, - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = false), - ), - ) - - assertThat(viewHolder.itemView.findViewById(R.id.question).text.toString()) - .isEqualTo("Question?") - } - } - - @Test - fun show_requiredText() { - runOnUI { - viewHolder.bind( - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent().apply { - linkId = "1" - required = true - text = "Question?" - }, - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - questionViewTextConfiguration = QuestionTextConfiguration(showRequiredText = true), - ), - ) - - assertThat( - viewHolder.itemView - .findViewById(R.id.multi_select_summary_holder) - .helperText - .toString(), - ) - .isEqualTo("Required") - } - } - - @Test - fun hide_requiredText() { - runOnUI { - viewHolder.bind( - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent().apply { - linkId = "1" - required = true - text = "Question?" - }, - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - questionViewTextConfiguration = QuestionTextConfiguration(showRequiredText = false), - ), - ) - - assertThat( - viewHolder.itemView - .findViewById(R.id.multi_select_summary_holder) - .helperText, - ) - .isNull() - } - } - - @Test - fun shows_optionalText() { - runOnUI { - viewHolder.bind( - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent().apply { linkId = "1" }, - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - questionViewTextConfiguration = QuestionTextConfiguration(showOptionalText = true), - ), - ) - assertThat( - viewHolder.itemView - .findViewById(R.id.multi_select_summary_holder) - .helperText - .toString(), - ) - .isEqualTo("Optional") - } - } - - @Test - fun hide_optionalText() { - runOnUI { - viewHolder.bind( - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent().apply { linkId = "1" }, - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - questionViewTextConfiguration = QuestionTextConfiguration(showOptionalText = false), - ), - ) - assertThat( - viewHolder.itemView - .findViewById(R.id.multi_select_summary_holder) - .helperText, - ) - .isNull() - } - } - - @Test - fun multipleChoice_doNotShowErrorInitially() { - val questionnaireViewItem = - QuestionnaireViewItem( - answerOptions(true, "Coding 1", "Coding 2", "Coding 3", "Coding 4", "Coding 5").apply { - required = true - }, - responseOptions(), - validationResult = NotValidated, - answersChangedCallback = { _, _, answers, _ -> }, - ) - - runOnUI { viewHolder.bind(questionnaireViewItem) } - - assertThat( - viewHolder.itemView.findViewById(R.id.multi_select_summary_holder).error, - ) - .isNull() - } - - @Test - fun multipleChoice_unselectSelectedAnswer_showErrorWhenNoAnswerIsSelected() { - val questionnaireViewItem = - QuestionnaireViewItem( - answerOptions(false, "Coding 1", "Coding 2").apply { required = true }, - responseOptions(), - validationResult = Invalid(listOf("Missing answer for required field.")), - answersChangedCallback = { _, _, answers, _ -> }, - ) - - runOnUI { viewHolder.bind(questionnaireViewItem) } - - endIconClickInTextInputLayout(R.id.multi_select_summary_holder) - clickOnTextInDialog("Coding 2") - clickOnText("Save") - assertDisplayedText().isEqualTo("Coding 2") - - endIconClickInTextInputLayout(R.id.multi_select_summary_holder) - clickOnTextInDialog("Coding 2") - clickOnText("Save") - - assertThat( - viewHolder.itemView.findViewById(R.id.multi_select_summary_holder).error, - ) - .isEqualTo("Missing answer for required field.") - } - - /** Method to run code snippet on UI/main thread */ - private fun runOnUI(action: () -> Unit) { - activityScenarioRule.scenario.onActivity { activity -> action() } - } - - /** Method to set content view for test activity */ - private fun setTestLayout(view: View) { - activityScenarioRule.scenario.onActivity { activity -> activity.setContentView(view) } - InstrumentationRegistry.getInstrumentation().waitForIdleSync() - } - - private fun assertDisplayedText(): StringSubject = - assertThat( - viewHolder.itemView.findViewById(R.id.multi_select_summary).text.toString(), - ) - - private val openChoiceType = - Extension().apply { - url = EXTENSION_ITEM_CONTROL_URL - setValue( - CodeableConcept() - .addCoding( - Coding() - .setCode(ItemControlTypes.OPEN_CHOICE.extensionCode) - .setDisplay("Open Choice") - .setSystem(EXTENSION_ITEM_CONTROL_SYSTEM), - ), - ) - } - - internal companion object { - private fun answerOptions(multiSelect: Boolean, vararg options: String) = - Questionnaire.QuestionnaireItemComponent().apply { - this.repeats = multiSelect - linkId = "1" - options.forEach { option -> - addAnswerOption( - Questionnaire.QuestionnaireItemAnswerOptionComponent().apply { - value = Coding().apply { display = option } - }, - ) - } - } - - private fun responseOptions(vararg responses: String) = - QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { - responses.forEach { response -> - addAnswer( - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { - value = Coding().apply { display = response } - }, - ) - } - } - } -} diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/QuestionnaireItemMultiSelectHolderFactoryInstrumentedTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/QuestionnaireItemMultiSelectHolderFactoryInstrumentedTest.kt deleted file mode 100644 index 99a6c34b5c..0000000000 --- a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/QuestionnaireItemMultiSelectHolderFactoryInstrumentedTest.kt +++ /dev/null @@ -1,176 +0,0 @@ -/* - * Copyright 2023 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.test.views - -import android.widget.FrameLayout -import android.widget.TextView -import androidx.test.annotation.UiThreadTest -import androidx.test.ext.junit.rules.activityScenarioRule -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.google.android.fhir.datacapture.R -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.views.QuestionnaireViewItem -import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemDialogSelectViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder -import com.google.android.material.textfield.TextInputLayout -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.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class QuestionnaireItemMultiSelectHolderFactoryInstrumentedTest { - @get:Rule val rule = activityScenarioRule() - - @Test - fun emptyResponseOptions_showNoneSelected() = withViewHolder { holder -> - holder.bind( - QuestionnaireViewItem( - answerOptions("Coding 1", "Coding 2"), - responseOptions(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ) - assertThat(holder.itemView.findViewById(R.id.multi_select_summary).text.toString()) - .isEqualTo("") - } - - @Test - fun selectedResponseOptions_showSelectedOptions() = withViewHolder { holder -> - holder.bind( - QuestionnaireViewItem( - answerOptions("Coding 1", "Coding 2", "Coding 3"), - responseOptions("Coding 1", "Coding 3"), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ) - assertThat(holder.itemView.findViewById(R.id.multi_select_summary).text.toString()) - .isEqualTo("Coding 1, Coding 3") - } - - @Test - @UiThreadTest - fun displayValidationResult_error_shouldShowErrorMessage() = withViewHolder { viewHolder -> - viewHolder.bind( - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent().apply { - linkId = "1" - required = true - }, - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = Invalid(listOf("Missing answer for required field.")), - answersChangedCallback = { _, _, _, _ -> }, - ), - ) - - assertThat( - viewHolder.itemView.findViewById(R.id.multi_select_summary_holder).error, - ) - .isEqualTo("Missing answer for required field.") - } - - @Test - @UiThreadTest - fun displayValidationResult_noError_shouldShowNoErrorMessage() = withViewHolder { viewHolder -> - viewHolder.bind( - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent().apply { - linkId = "1" - required = true - addAnswerOption( - Questionnaire.QuestionnaireItemAnswerOptionComponent().apply { - value = Coding().apply { display = "display" } - }, - ) - }, - QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { - addAnswer( - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { - value = Coding().apply { display = "display" } - }, - ) - }, - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ) - - assertThat( - viewHolder.itemView.findViewById(R.id.multi_select_summary_holder).error, - ) - .isNull() - } - - @Test - fun bind_readOnly_shouldDisableView() = withViewHolder { holder -> - holder.bind( - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent().apply { - linkId = "1" - readOnly = true - }, - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ) - - assertThat( - holder.itemView.findViewById(R.id.multi_select_summary_holder).isEnabled, - ) - .isFalse() - } - - private inline fun withViewHolder( - crossinline block: TestActivity.(QuestionnaireItemViewHolder) -> Unit, - ) { - rule.scenario.onActivity { - block(it, QuestionnaireItemDialogSelectViewHolderFactory.create(FrameLayout(it))) - } - } -} - -private fun answerOptions(vararg options: String) = - Questionnaire.QuestionnaireItemComponent().apply { - linkId = "1" - repeats = true - options.forEach { option -> - addAnswerOption( - Questionnaire.QuestionnaireItemAnswerOptionComponent().apply { - value = Coding().apply { display = option } - }, - ) - } - } - -private fun responseOptions(vararg responses: String) = - QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { - responses.forEach { response -> - addAnswer( - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { - value = Coding().apply { display = response } - }, - ) - } - } 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 index 544aef30fc..bfd2d8c93d 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireLists.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireLists.kt @@ -43,6 +43,7 @@ import com.google.android.fhir.datacapture.views.factories.BooleanChoiceViewHold 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.DialogSelectViewHolderFactory 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 @@ -51,7 +52,6 @@ import com.google.android.fhir.datacapture.views.factories.EditTextMultiLineView 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 @@ -282,7 +282,7 @@ private fun getQuestionnaireItemViewHolderFactory( QuestionnaireViewHolderType.QUANTITY -> QuantityViewHolderFactory QuestionnaireViewHolderType.CHECK_BOX_GROUP -> CheckBoxGroupViewHolderFactory QuestionnaireViewHolderType.AUTO_COMPLETE -> AutoCompleteViewHolderFactory - QuestionnaireViewHolderType.DIALOG_SELECT -> QuestionnaireItemDialogSelectViewHolderFactory + QuestionnaireViewHolderType.DIALOG_SELECT -> DialogSelectViewHolderFactory QuestionnaireViewHolderType.SLIDER -> SliderViewHolderFactory QuestionnaireViewHolderType.PHONE_NUMBER -> PhoneNumberViewHolderFactory QuestionnaireViewHolderType.ATTACHMENT -> AttachmentViewHolderFactory diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/OptionSelectDialogFragment.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/OptionSelectDialogFragment.kt deleted file mode 100644 index ab40dc3eb1..0000000000 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/OptionSelectDialogFragment.kt +++ /dev/null @@ -1,459 +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.views - -import android.app.Dialog -import android.os.Build -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.WindowManager -import android.widget.Button -import android.widget.CheckBox -import android.widget.EditText -import android.widget.RadioButton -import android.widget.TextView -import androidx.appcompat.view.ContextThemeWrapper -import androidx.core.content.res.use -import androidx.core.widget.doAfterTextChanged -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.google.android.fhir.datacapture.R -import com.google.android.fhir.datacapture.extensions.itemAnswerOptionImage -import com.google.android.fhir.datacapture.extensions.optionExclusive -import com.google.android.fhir.datacapture.extensions.toSpanned -import com.google.android.fhir.datacapture.views.factories.OptionSelectOption -import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemDialogSelectViewModel -import com.google.android.fhir.datacapture.views.factories.SelectedOptions -import com.google.android.material.button.MaterialButton -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import java.util.concurrent.atomic.AtomicInteger -import kotlinx.coroutines.launch - -internal class OptionSelectDialogFragment( - private val title: CharSequence, - private val config: Config, - private val selectedOptions: SelectedOptions, -) : DialogFragment() { - - /** Configures this [OptionSelectDialogFragment]. */ - data class Config( - /** Whether multi-select will be enabled or not. */ - val multiSelect: Boolean, - /** Whether the "other" option will be exposed for free-form text input. */ - val otherOptionsAllowed: Boolean, - ) - - private val viewModel: QuestionnaireItemDialogSelectViewModel by activityViewModels() - - private val questionLinkId: String by lazy { arguments?.getString(KEY_QUESTION_LINK_ID)!! } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - isCancelable = false - - val themeId = - requireContext().obtainStyledAttributes(R.styleable.QuestionnaireTheme).use { - it.getResourceId( - // Use the custom questionnaire theme if it is specified - R.styleable.QuestionnaireTheme_questionnaire_theme, - // Otherwise, use the default questionnaire theme - R.style.Theme_Questionnaire, - ) - } - - val dialogThemeContext = ContextThemeWrapper(requireContext(), themeId) - val view = LayoutInflater.from(dialogThemeContext).inflate(R.layout.multi_select_dialog, null) - - val recyclerView: RecyclerView = view.findViewById(R.id.recycler_view) - recyclerView.layoutManager = LinearLayoutManager(requireContext()) - recyclerView.addItemDecoration( - MarginItemDecoration( - marginVertical = resources.getDimensionPixelOffset(R.dimen.option_item_margin_vertical), - marginHorizontal = resources.getDimensionPixelOffset(R.dimen.option_item_margin_horizontal), - ), - ) - - val adapter = OptionSelectAdapter(multiSelectEnabled = config.multiSelect) - recyclerView.adapter = adapter - adapter.submitList(selectedOptions.toOptionRows()) - - val dialog = - MaterialAlertDialogBuilder(requireContext()).setView(view).create().apply { - setOnShowListener { - dialog?.window?.let { - // Android: EditText in Dialog doesn't pull up soft keyboard - // https://stackoverflow.com/a/9118027 - it.clearFlags( - WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or - WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM, - ) - // Adjust the dialog after the keyboard is on so that OK-CANCEL buttons are visible. - // Ideally SOFT_INPUT_ADJUST_RESIZE supposed to be used, but in some devices the - // keyboard immediately hide itself after being opened, that's why SOFT_INPUT_ADJUST_PAN - // is used instead. There's no issue with setDecorFitsSystemWindows and is only - // available for api level 30 and above. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - it.setDecorFitsSystemWindows(false) - } else { - it.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN) - } - } - } - } - - view.findViewById(R.id.dialog_title).text = title - view.findViewById(R.id.positive_button).setOnClickListener { - saveSelections(adapter.currentList) - dialog.dismiss() - } - view.findViewById(R.id.negative_button).setOnClickListener { dialog.dismiss() } - - return dialog - } - - /** Saves the current selections in the RecyclerView into the ViewModel. */ - private fun saveSelections(currentList: List) { - lifecycleScope.launch { - viewModel.updateSelectedOptions( - questionLinkId, - SelectedOptions( - options = currentList.filterIsInstance().map { it.option }, - otherOptions = - currentList - .filterIsInstance() - .filter { - it.currentText.isNotEmpty() - } // Filters out empty answers when the user inputs nothing into a new option choice - // edit text field. - .map { it.currentText }, - ), - ) - } - } - - /** Converts the initial ViewModel state into rows to display in the RecyclerView adapter. */ - private fun SelectedOptions.toOptionRows(): List { - val rows = mutableListOf() - options.forEach { rows += OptionSelectRow.Option(it) } - if (otherOptions.isEmpty()) { - // No other options selected; just show an "Other" option if the config allows it - if (config.otherOptionsAllowed) { - rows += OptionSelectRow.OtherRow(selected = false) - } - } else { - // Other options were selected; show the "Other" option (selected) and any of those custom - // options in EditTexts - rows += OptionSelectRow.OtherRow(selected = true) - if (!config.multiSelect) { - check(otherOptions.size == 1) { - "Multiple 'Other' options selected in single-select mode: $otherOptions" - } - } - rows += otherOptions.map { OptionSelectRow.OtherEditText.fromText(it) } - } - return rows.sanitizeOtherOptionRows(multiSelectEnabled = config.multiSelect) - } - - companion object { - const val KEY_QUESTION_LINK_ID = "key-question-link-id" - } -} - -private class OptionSelectAdapter(val multiSelectEnabled: Boolean) : - ListAdapter(DIFF_CALLBACK) { - lateinit var recyclerView: RecyclerView - - override fun getItemViewType(position: Int): Int = - when (getItem(position)) { - is OptionSelectRow.Option, - is OptionSelectRow.OtherRow, -> - if (multiSelectEnabled) Types.OPTION_MULTI else Types.OPTION_SINGLE - is OptionSelectRow.OtherEditText -> Types.OTHER_EDIT_TEXT - OptionSelectRow.OtherAddAnother -> Types.OTHER_ADD_ANOTHER - }.ordinal - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): OptionSelectViewHolder = - when (Types.values()[viewType]) { - Types.OPTION_SINGLE -> OptionSelectViewHolder.OptionSingle(parent) - Types.OPTION_MULTI -> OptionSelectViewHolder.OptionMulti(parent) - Types.OTHER_EDIT_TEXT -> OptionSelectViewHolder.OtherEditText(parent) - Types.OTHER_ADD_ANOTHER -> OptionSelectViewHolder.OtherAddAnother(parent) - } - - override fun onBindViewHolder(holder: OptionSelectViewHolder, position: Int) { - when (val item = getItem(position)) { - is OptionSelectRow.Option -> { - val compoundButton = - if (multiSelectEnabled) { - (holder as OptionSelectViewHolder.OptionMulti).checkbox - } else { - (holder as OptionSelectViewHolder.OptionSingle).radioButton - } - compoundButton.text = item.option.displayString.toSpanned() - compoundButton.setCompoundDrawablesRelative( - item.option.item.itemAnswerOptionImage(compoundButton.context), - null, - null, - null, - ) - compoundButton.setOnCheckedChangeListener(null) - compoundButton.isChecked = item.option.selected - compoundButton.setOnCheckedChangeListener { _, checked -> - submitSelectedChange(position = holder.adapterPosition, selected = checked) - } - } - is OptionSelectRow.OtherRow -> { - val compoundButton = - if (multiSelectEnabled) { - (holder as OptionSelectViewHolder.OptionMulti).checkbox - } else { - (holder as OptionSelectViewHolder.OptionSingle).radioButton - } - compoundButton.setText(R.string.open_choice_other) - compoundButton.setOnCheckedChangeListener(null) - compoundButton.isChecked = item.selected - compoundButton.setOnCheckedChangeListener { _, checked -> - submitSelectedChange(position = holder.adapterPosition, selected = checked) - // Scroll down the recyclerview to show the Add another answer button on the screen. - if (checked) { - recyclerView.smoothScrollToPosition(this@OptionSelectAdapter.itemCount) - } - } - } - is OptionSelectRow.OtherEditText -> { - holder as OptionSelectViewHolder.OtherEditText - holder.delete.visibility = if (multiSelectEnabled) View.VISIBLE else View.GONE - holder.delete.setOnClickListener { - val newList = currentList.filterIndexed { index, _ -> index != holder.adapterPosition } - submitList(newList) - } - holder.editText.setText(item.startingText) - holder.currentItem = item - } - OptionSelectRow.OtherAddAnother -> { - holder as OptionSelectViewHolder.OtherAddAnother - holder.addAnother.setOnClickListener { - // Add another blank OtherEditText right before this button - val newList = - currentList.toMutableList().also { - it.add(holder.adapterPosition, OptionSelectRow.OtherEditText.fromText("")) - } - submitList(newList) - // Scroll down the recyclerview to show the Add another answer button on the screen. - recyclerView.smoothScrollToPosition(this@OptionSelectAdapter.itemCount) - } - } - } - } - - /** - * Sets the item at [position] to selected = [selected]. - * - * Also ensures that the state is cleaned up to a valid state (eg, removing "Other" editable rows - * if "Other" was just deselected, or adding them if "Other" was just selected). - */ - private fun submitSelectedChange(position: Int, selected: Boolean) { - val selectedItem = currentList[position] - - val newList: List = - currentList - .mapIndexed { index, row -> - if (index == position) { - // This is the row we are changing - row.withSelectedState(selected = selected)!! - } else { - // This is some other row - if (multiSelectEnabled) { - // In multi-select mode, - if ( - selected && - ((selectedItem is OptionSelectRow.Option && - selectedItem.option.item.optionExclusive) || - (row is OptionSelectRow.Option && row.option.item.optionExclusive)) - ) { - // if the selected answer option has optionExclusive extension, then deselect other - // answer options. - // or if the selected answer option does not have optionExclusive extension, then - // deselect optionExclusive answer option. - row.withSelectedState(selected = false) ?: row - } else { - // the other rows don't need to change - row - } - } else { - // In single-select mode, we need to disable all of the other rows - row.withSelectedState(selected = false) ?: row - } - } - } - .sanitizeOtherOptionRows(multiSelectEnabled = multiSelectEnabled) - submitList(newList) - } - - private fun OptionSelectRow.withSelectedState(selected: Boolean): OptionSelectRow? = - when (this) { - is OptionSelectRow.Option -> copy(option = option.copy(selected = selected)) - is OptionSelectRow.OtherRow -> copy(selected = selected) - OptionSelectRow.OtherAddAnother, - is OptionSelectRow.OtherEditText, -> null - } - - private enum class Types { - OPTION_SINGLE, - OPTION_MULTI, - OTHER_EDIT_TEXT, - OTHER_ADD_ANOTHER, - } - - override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { - super.onAttachedToRecyclerView(recyclerView) - this.recyclerView = recyclerView - } -} - -private fun List.sanitizeOtherOptionRows( - multiSelectEnabled: Boolean, -): List { - var sanitized = this - // Now that we've set the selected states properly, we need to make sure that the "Other" rows - // are showing in their correct state - val shouldShowOtherRows = sanitized.any { it is OptionSelectRow.OtherRow && it.selected } - if (shouldShowOtherRows) { - if (multiSelectEnabled) { - // In multi-select with Other enabled, we need the last row to be an AddAnother button - if (sanitized.last() !is OptionSelectRow.OtherAddAnother) { - sanitized = sanitized + OptionSelectRow.OtherAddAnother - } - } else { - // In single-select with Other enabled, the last row should just be an OtherEditText - if (sanitized.last() !is OptionSelectRow.OtherEditText) { - sanitized = sanitized + OptionSelectRow.OtherEditText.fromText("") - } - } - } else { - // We should not show the "Other" edit-texts or Add Another buttons, so return a sub-list with - // those items dropped - sanitized = - sanitized.dropLastWhile { - when (it) { - // don't drop these - is OptionSelectRow.Option, - is OptionSelectRow.OtherRow, -> false - // drop these - is OptionSelectRow.OtherEditText, - OptionSelectRow.OtherAddAnother, -> true - } - } - } - return sanitized -} - -private sealed class OptionSelectRow { - /** A predefined option. */ - data class Option(val option: OptionSelectOption) : OptionSelectRow() - - /** - * "Other" option. Only shown if [OptionSelectDialogFragment.Config.otherOptionsAllowed] is true. - */ - data class OtherRow(val selected: Boolean) : OptionSelectRow() - - /** Text boxes for user to enter "Other" options in. Only shown when [OtherRow] is selected. */ - data class OtherEditText(val id: Int, val startingText: String) : OptionSelectRow() { - /** - * We track the text as the user edits it in a separate variable, so that DiffUtil doesn't pick - * up the changes the user makes while editing and send the adapter into an infinite loop. - */ - var currentText: String = startingText - - companion object { - fun fromText(text: String) = - OtherEditText( - id = idProvider.getAndIncrement(), - startingText = text, - ) - - private val idProvider = AtomicInteger(0) - } - } - - /** "Add Another" other field button. Only used in multi-select when [OtherRow] is selected. */ - object OtherAddAnother : OptionSelectRow() -} - -private sealed class OptionSelectViewHolder(parent: ViewGroup, layout: Int) : - RecyclerView.ViewHolder(LayoutInflater.from(parent.context).inflate(layout, parent, false)) { - /** Radio button option. */ - class OptionSingle(parent: ViewGroup) : - OptionSelectViewHolder(parent, R.layout.option_item_single) { - val radioButton: RadioButton = itemView.findViewById(R.id.radio_button) - } - - /** Checkbox option. */ - class OptionMulti(parent: ViewGroup) : - OptionSelectViewHolder(parent, R.layout.option_item_multi) { - val checkbox: CheckBox = itemView.findViewById(R.id.checkbox) - } - - /** - * Freeform option, only shown if [OptionSelectDialogFragment.Config.otherOptionsAllowed] is true. - */ - class OtherEditText(parent: ViewGroup) : - OptionSelectViewHolder(parent, R.layout.option_item_other_text) { - val editText: EditText = itemView.findViewById(R.id.edit_text) - val delete: View = itemView.findViewById(R.id.delete_button) - - var currentItem: OptionSelectRow.OtherEditText? = null - - init { - editText.doAfterTextChanged { - val text = it?.toString().orEmpty() - currentItem?.currentText = text - } - } - } - - /** - * Freeform option, only shown if [OptionSelectDialogFragment.Config.otherOptionsAllowed] is true. - */ - class OtherAddAnother(parent: ViewGroup) : - OptionSelectViewHolder(parent, R.layout.option_item_other_add_another) { - val addAnother: Button = itemView.findViewById(R.id.add_another) - } -} - -private val DIFF_CALLBACK = - object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(l: OptionSelectRow, r: OptionSelectRow): Boolean = - when (l) { - is OptionSelectRow.Option -> - r is OptionSelectRow.Option && l.option.displayString == r.option.displayString - is OptionSelectRow.OtherEditText -> r is OptionSelectRow.OtherEditText && l.id == r.id - - // These two types should only have 1 instance present in the whole RecyclerView, so they - // only need an instanceof check. - is OptionSelectRow.OtherRow -> r is OptionSelectRow.OtherRow - OptionSelectRow.OtherAddAnother -> r == OptionSelectRow.OtherAddAnother - } - - override fun areContentsTheSame(l: OptionSelectRow, r: OptionSelectRow): Boolean = l == r - } diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/ChoiceCheckbox.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/ChoiceCheckbox.kt new file mode 100644 index 0000000000..e210ea62d5 --- /dev/null +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/ChoiceCheckbox.kt @@ -0,0 +1,136 @@ +/* + * 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 android.graphics.drawable.Drawable +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toBitmap +import com.google.android.fhir.datacapture.R + +@Composable +internal fun ChoiceCheckbox( + label: AnnotatedString, + checked: Boolean, + enabled: Boolean, + modifier: Modifier = Modifier, + image: Drawable? = null, + onCheckedChange: (Boolean) -> Unit, +) { + val backgroundColor = + if (checked) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) + } else { + MaterialTheme.colorScheme.surface + } + + val borderColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.15f) + val textColor = + if (checked) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + } + + val shape = + if (checked) { + RoundedCornerShape(4.dp) + } else { + RoundedCornerShape(8.dp) + } + + Row( + modifier = + modifier + .clip(shape) + .background(backgroundColor) + .then( + if (!checked) { + Modifier.border(1.dp, borderColor, shape) + } else { + Modifier + }, + ) + .toggleable( + value = checked, + enabled = enabled, + role = Role.Checkbox, + onValueChange = onCheckedChange, + ) + .padding( + start = dimensionResource(R.dimen.option_item_between_text_and_icon_padding), + end = dimensionResource(R.dimen.option_item_after_text_padding), + top = dimensionResource(R.dimen.option_item_padding), + bottom = dimensionResource(R.dimen.option_item_padding), + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = checked, + onCheckedChange = null, + enabled = enabled, + colors = + CheckboxDefaults.colors( + checkedColor = MaterialTheme.colorScheme.primary, + uncheckedColor = MaterialTheme.colorScheme.onSurface, + checkmarkColor = MaterialTheme.colorScheme.surface, + ), + ) + // Display image + image?.let { drawable -> + Spacer(modifier = Modifier.width(8.dp)) + Icon( + bitmap = drawable.toBitmap().asImageBitmap(), + contentDescription = null, + modifier = Modifier.testTag(CHOICE_CHECKBOX_IMAGE_TAG).size(24.dp), + ) + } + Spacer( + modifier = + Modifier.width(dimensionResource(R.dimen.option_item_between_text_and_icon_padding)), + ) + Text( + text = label, + color = textColor, + ) + Spacer(modifier = Modifier.width(dimensionResource(R.dimen.option_item_after_text_padding))) + } +} + +const val CHOICE_CHECKBOX_IMAGE_TAG = "checkbox_option_icon" diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/ChoiceRadioButton.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/ChoiceRadioButton.kt new file mode 100644 index 0000000000..7871666380 --- /dev/null +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/ChoiceRadioButton.kt @@ -0,0 +1,129 @@ +/* + * 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 android.graphics.drawable.Drawable +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toBitmap +import com.google.android.fhir.datacapture.R + +@Composable +internal fun ChoiceRadioButton( + label: AnnotatedString, + selected: Boolean, + enabled: Boolean, + modifier: Modifier = Modifier, + image: Drawable? = null, + onClick: () -> Unit, +) { + val backgroundColor = + if (selected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) + } else { + MaterialTheme.colorScheme.surface + } + + val borderColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.15f) + val textColor = + if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + } + + val shape = + if (selected) { + RoundedCornerShape(4.dp) + } else { + RoundedCornerShape(8.dp) + } + + Row( + modifier = + modifier + .clip(shape) + .background(backgroundColor) + .then( + if (!selected) { + Modifier.border(1.dp, borderColor, shape) + } else { + Modifier + }, + ) + .selectable( + selected = selected, + enabled = enabled, + role = Role.RadioButton, + onClick = onClick, + ) + .padding( + start = dimensionResource(R.dimen.option_item_between_text_and_icon_padding), + end = dimensionResource(R.dimen.option_item_after_text_padding), + top = dimensionResource(R.dimen.option_item_padding), + bottom = dimensionResource(R.dimen.option_item_padding), + ), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = selected, + onClick = null, + enabled = enabled, + ) + // Display image + image?.let { drawable -> + Spacer(modifier = Modifier.width(8.dp)) + Icon( + bitmap = drawable.toBitmap().asImageBitmap(), + contentDescription = null, + modifier = Modifier.testTag(CHOICE_RADIO_BUTTON_IMAGE_TAG).size(24.dp), + ) + } + Spacer( + modifier = + Modifier.width(dimensionResource(R.dimen.option_item_between_text_and_icon_padding)), + ) + Text( + text = label, + color = textColor, + ) + Spacer(modifier = Modifier.width(dimensionResource(R.dimen.option_item_after_text_padding))) + } +} + +const val CHOICE_RADIO_BUTTON_IMAGE_TAG = "radio_button_option_icon" diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/OptionDialogSelect.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/OptionDialogSelect.kt new file mode 100644 index 0000000000..645697f202 --- /dev/null +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/OptionDialogSelect.kt @@ -0,0 +1,397 @@ +/* + * 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 android.content.Context +import android.graphics.drawable.Drawable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.google.android.fhir.datacapture.R +import com.google.android.fhir.datacapture.extensions.displayString +import com.google.android.fhir.datacapture.extensions.itemAnswerOptionImage +import com.google.android.fhir.datacapture.extensions.optionExclusive +import com.google.android.fhir.datacapture.extensions.toAnnotatedString +import java.util.concurrent.atomic.AtomicInteger +import org.hl7.fhir.r4.model.Questionnaire + +@Composable +fun OptionDialogSelect( + context: Context, + title: AnnotatedString, + multiSelect: Boolean, + otherOptionsAllowed: + Boolean, // Client had to specify that they want an open-choice control to use "Other" options + selectedOptions: SelectedOptions, + onDismiss: () -> Unit, + onConfirm: (SelectedOptions) -> Unit, +) { + var choiceOptions by + remember(multiSelect, selectedOptions.options) { + mutableStateOf(selectedOptions.options.map { OptionSelectRow.Option(it) }) + } + var otherOptionRowSelected by + remember(otherOptionsAllowed, selectedOptions.otherOptions) { + mutableStateOf(otherOptionsAllowed && selectedOptions.otherOptions.isNotEmpty()) + } + val otherOptionEditTexts = + remember(otherOptionsAllowed, selectedOptions.otherOptions) { + val list = selectedOptions.otherOptions.map { OptionSelectRow.OtherEditText.fromText(it) } + mutableStateListOf(*list.toTypedArray()) + } + val showAddAnother = + remember(otherOptionRowSelected, multiSelect) { otherOptionRowSelected && multiSelect } + val listState = rememberLazyListState() + + LaunchedEffect(otherOptionRowSelected, otherOptionEditTexts.size) { + if (otherOptionRowSelected) { + val listSize = + choiceOptions.size + + if (otherOptionsAllowed) { + 1 + } else { + 0 + } + + otherOptionEditTexts.size + + if (showAddAnother) 1 else 0 + listState.animateScrollToItem(listSize - 1) + } + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false), + ) { + Surface( + shape = MaterialTheme.shapes.extraLarge, + tonalElevation = 6.dp, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(dimensionResource(R.dimen.dialog_padding))) { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(dimensionResource(R.dimen.dialog_title_margin_bottom)), + ) + + LazyColumn( + state = listState, + modifier = + Modifier.fillMaxWidth() + .testTag(OPTION_CHOICE_LIST_TAG) + .weight(1f, fill = false) + .padding(top = dimensionResource(R.dimen.dialog_option_scroll_margin_top)), + verticalArrangement = + Arrangement.spacedBy(dimensionResource(R.dimen.option_item_margin_vertical)), + horizontalAlignment = Alignment.CenterHorizontally, + contentPadding = + PaddingValues(horizontal = dimensionResource(R.dimen.option_item_margin_horizontal)), + ) { + itemsIndexed( + choiceOptions, + key = { _, row -> row.key() }, + contentType = { _, row -> row::class.simpleName }, + ) { index, optionSelectRow -> + val label = optionSelectRow.option.displayString.toAnnotatedString() + val image = optionSelectRow.option.item.itemAnswerOptionImage(context) + OptionChoice( + modifier = Modifier.fillMaxWidth(), + isMultiSelect = multiSelect, + label = label, + selected = optionSelectRow.option.selected, + image = image, + ) { selected -> + choiceOptions = + choiceOptions.mapIndexed { ind, option -> + when { + ind == index || option == optionSelectRow -> { + optionSelectRow.copy( + option = optionSelectRow.option.copy(selected = selected), + ) + } + selected && + multiSelect && + (optionSelectRow.option.item.optionExclusive || + option.option.item.optionExclusive) -> { + // if the selected answer option has optionExclusive extension, then deselect + // other + // answer options. + // or if the selected answer option does not have optionExclusive extension, + // then + // deselect optionExclusive answer option. + option.copy(option = option.option.copy(selected = false)) + } + !multiSelect -> { + // In single-select mode, we need to disable all of the other rows + option.copy(option = option.option.copy(selected = false)) + } + else -> { + option + } + } + } + + if (selected && (!multiSelect || optionSelectRow.option.item.optionExclusive)) { + otherOptionRowSelected = false + otherOptionEditTexts.clear() + } + } + } + + if (otherOptionsAllowed) { + item { + val label = AnnotatedString(stringResource(R.string.open_choice_other)) + OptionChoice( + modifier = Modifier.fillMaxWidth(), + isMultiSelect = multiSelect, + label = label, + selected = otherOptionRowSelected, + ) { selected -> + otherOptionRowSelected = selected + when { + selected && !multiSelect -> { + choiceOptions = + choiceOptions.map { it.copy(option = it.option.copy(selected = false)) } + } + selected -> { + choiceOptions = + choiceOptions.map { + if (it.option.item.optionExclusive) { + it.copy(option = it.option.copy(selected = false)) + } else { + it + } + } + } + } + + if (selected && otherOptionEditTexts.isEmpty()) { + otherOptionEditTexts.add(OptionSelectRow.OtherEditText.fromText("")) + } + } + } + } + + if (otherOptionRowSelected) { + itemsIndexed( + otherOptionEditTexts, + key = { _, option -> option.key() }, + contentType = { _, _ -> OptionSelectRow.OtherEditText }, + ) { index, option -> + Row( + modifier = + Modifier.testTag(OTHER_OPTION_TEXT_FIELD_TAG) + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = option.currentText, + onValueChange = { newText -> + otherOptionEditTexts.removeAt(index) + otherOptionEditTexts.add(index, option.copy(currentText = newText)) + }, + modifier = Modifier.weight(1f), + placeholder = { Text(stringResource(R.string.open_choice_other_hint)) }, + ) + + if (multiSelect) { + IconButton( + onClick = { otherOptionEditTexts.removeAt(index) }, + ) { + Icon( + painterResource(R.drawable.delete_24px), + contentDescription = stringResource(R.string.delete), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + + if (showAddAnother) { + item { + Button( + onClick = { otherOptionEditTexts.add(OptionSelectRow.OtherEditText.fromText("")) }, + ) { + Text(stringResource(R.string.open_choice_other_add_another)) + } + } + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton( + modifier = + Modifier.padding(dimensionResource(R.dimen.dialog_confirmation_button_padding)), + onClick = onDismiss, + ) { + Text( + stringResource(android.R.string.cancel), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + TextButton( + modifier = + Modifier.padding(dimensionResource(R.dimen.dialog_confirmation_button_padding)), + onClick = { + onConfirm( + SelectedOptions( + options = choiceOptions.map { it.option }, + otherOptions = + otherOptionEditTexts.map { it.currentText }.filterNot { it.isBlank() }, + ), + ) + onDismiss() + }, + ) { + Text( + stringResource(R.string.save), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } +} + +@Composable +internal fun OptionChoice( + modifier: Modifier, + isMultiSelect: Boolean, + label: AnnotatedString, + selected: Boolean, + image: Drawable? = null, + onSelectionChange: (Boolean) -> Unit, +) { + val testTagModifier = modifier.testTag(OPTION_CHOICE_TAG) + if (isMultiSelect) { + ChoiceCheckbox( + label = label, + checked = selected, + enabled = true, + image = image, + onCheckedChange = { checked -> onSelectionChange(checked) }, + modifier = testTagModifier, + ) + } else { + ChoiceRadioButton( + label = label, + selected = selected, + enabled = true, + image = image, + onClick = { onSelectionChange(!selected) }, + modifier = testTagModifier, + ) + } +} + +data class SelectedOptions( + val options: List, + val otherOptions: List, +) { + val selectedSummary: String = + (options.filter { it.selected }.map { it.displayString } + otherOptions).joinToString() +} + +/** Represents selectable options in the multi-select page. */ +data class OptionSelectOption( + val item: Questionnaire.QuestionnaireItemAnswerOptionComponent, + val selected: Boolean, + val context: Context, +) { + val displayString: String = item.value.displayString(context) +} + +/** Sealed class representing different types of rows in the option select dialog. */ +internal sealed class OptionSelectRow { + /** A predefined option. */ + data class Option(val option: OptionSelectOption) : OptionSelectRow() + + /** "Other" option. Only shown if otherOptionsAllowed is true. */ + data class OtherRow(val selected: Boolean) : OptionSelectRow() + + /** Text boxes for user to enter "Other" options in. Only shown when [OtherRow] is selected. */ + data class OtherEditText(val id: Int, val startingText: String, var currentText: String) : + OptionSelectRow() { + companion object { + fun fromText(text: String) = + OtherEditText( + id = idProvider.getAndIncrement(), + startingText = text, + currentText = text, + ) + + private val idProvider = AtomicInteger(0) + } + } + + /** "Add Another" other field button. Only used in multi-select when [OtherRow] is selected. */ + object OtherAddAnother : OptionSelectRow() + + fun key(): Any = + when (this) { + is Option -> "option_${option.displayString}" + is OtherRow -> "other_row" + is OtherEditText -> "other_edit_$id" + OtherAddAnother -> "add_another" + } +} + +internal const val OPTION_CHOICE_LIST_TAG = "dialog_select_option_choice_list" +internal const val OPTION_CHOICE_TAG = "dialog_select_option_choice" +internal const val OTHER_OPTION_TEXT_FIELD_TAG = "other_option_edit_text_field" diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DialogSelectViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DialogSelectViewHolderFactory.kt index 11a9f77756..ec7728ab88 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DialogSelectViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DialogSelectViewHolderFactory.kt @@ -16,179 +16,170 @@ package com.google.android.fhir.datacapture.views.factories -import android.annotation.SuppressLint import android.content.Context -import android.view.View -import android.widget.TextView -import androidx.activity.viewModels -import androidx.core.os.bundleOf -import androidx.lifecycle.ViewModel -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.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +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.platform.testTag +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.semantics.error +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.extensions.ItemControlTypes import com.google.android.fhir.datacapture.extensions.asStringValue -import com.google.android.fhir.datacapture.extensions.displayString import com.google.android.fhir.datacapture.extensions.getRequiredOrOptionalText import com.google.android.fhir.datacapture.extensions.getValidationErrorMessage import com.google.android.fhir.datacapture.extensions.itemControl -import com.google.android.fhir.datacapture.extensions.localizedFlyoverSpanned -import com.google.android.fhir.datacapture.extensions.toSpanned -import com.google.android.fhir.datacapture.extensions.tryUnwrapContext -import com.google.android.fhir.datacapture.validation.ValidationResult -import com.google.android.fhir.datacapture.views.HeaderView -import com.google.android.fhir.datacapture.views.OptionSelectDialogFragment +import com.google.android.fhir.datacapture.extensions.itemMedia +import com.google.android.fhir.datacapture.extensions.localizedFlyoverAnnotatedString import com.google.android.fhir.datacapture.views.QuestionnaireViewItem -import com.google.android.material.textfield.TextInputLayout -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow +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.OptionDialogSelect +import com.google.android.fhir.datacapture.views.compose.OptionSelectOption +import com.google.android.fhir.datacapture.views.compose.SelectedOptions +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.StringType -internal object QuestionnaireItemDialogSelectViewHolderFactory : - QuestionnaireItemAndroidViewHolderFactory(R.layout.option_select_view) { +internal object DialogSelectViewHolderFactory : QuestionnaireItemComposeViewHolderFactory { + @OptIn(ExperimentalMaterial3Api::class) override fun getQuestionnaireItemViewHolderDelegate() = - @SuppressLint("StaticFieldLeak") - object : QuestionnaireItemAndroidViewHolderDelegate { - private lateinit var holder: DialogSelectViewHolder - override lateinit var questionnaireViewItem: QuestionnaireViewItem - private var selectedOptionsJob: Job? = null - - override fun init(itemView: View) { - holder = DialogSelectViewHolder(itemView) - } - - override fun bind(questionnaireViewItem: QuestionnaireViewItem) { - cleanupOldState() - with(holder.summaryHolder) { - hint = questionnaireViewItem.enabledDisplayItems.localizedFlyoverSpanned - helperText = getRequiredOrOptionalText(questionnaireViewItem, context) - } - val activity = - requireNotNull(holder.header.context.tryUnwrapContext()) { - "Can only use dialog select in an AppCompatActivity context" + object : QuestionnaireItemComposeViewHolderDelegate { + + @Composable + override fun Content(questionnaireViewItem: QuestionnaireViewItem) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope { Dispatchers.Main } + val readOnly = + remember(questionnaireViewItem) { questionnaireViewItem.questionnaireItem.readOnly } + val hintLabelText = + remember(questionnaireViewItem) { + questionnaireViewItem.enabledDisplayItems.localizedFlyoverAnnotatedString } - val viewModel: QuestionnaireItemDialogSelectViewModel by activity.viewModels() - - // Bind static data - holder.header.bind(questionnaireViewItem) - - val questionnaireItem = questionnaireViewItem.questionnaireItem - val selectedOptions = questionnaireViewItem.extractInitialOptions(holder.header.context) - holder.summary.text = selectedOptions.selectedSummary.toSpanned() - selectedOptionsJob = - activity.lifecycleScope.launch { - // Listen for changes to selected options to update summary + FHIR data model - viewModel.getSelectedOptionsFlow(questionnaireItem.linkId).collect { selectedOptions -> - holder.summary.text = selectedOptions.selectedSummary.toSpanned() - updateAnswers(selectedOptions) + val validationResultMessage = + remember(questionnaireViewItem) { + getValidationErrorMessage( + context, + questionnaireViewItem, + questionnaireViewItem.validationResult, + ) + } + val hasValidationError = + remember(validationResultMessage) { !validationResultMessage.isNullOrBlank() } + val supportingHelperText = + remember(questionnaireViewItem) { + if (hasValidationError) { + validationResultMessage + } else { + getRequiredOrOptionalText(questionnaireViewItem, context) } } - - // When dropdown is clicked, show dialog - val onClick = - View.OnClickListener { - val fragment = - OptionSelectDialogFragment( - // We use the question text for the dialog title. If there is no question text, we - // use flyover text as it is sometimes used in text fields instead of question text. - title = questionnaireViewItem.questionText - ?: questionnaireItem.localizedFlyoverSpanned ?: "", - config = questionnaireItem.buildConfig(), - selectedOptions = selectedOptions, - ) - fragment.arguments = - bundleOf( - OptionSelectDialogFragment.KEY_QUESTION_LINK_ID to questionnaireItem.linkId, - ) - fragment.show(activity.supportFragmentManager, null) + var selectedOptions by + remember(questionnaireViewItem) { + mutableStateOf(questionnaireViewItem.extractInitialOptions(context)) + } + val selectedOptionsString = remember(selectedOptions) { selectedOptions.selectedSummary } + val dialogTitle = + remember(questionnaireViewItem) { + questionnaireViewItem.questionTextAnnotatedString + ?: hintLabelText ?: AnnotatedString("") + } + val isMultiSelect = + remember(questionnaireViewItem) { questionnaireViewItem.questionnaireItem.repeats } + val allowOtherOptions = + remember(questionnaireViewItem) { + questionnaireViewItem.questionnaireItem.itemControl == ItemControlTypes.OPEN_CHOICE } - // We need to set the click-listener on both the summary TextView, and the endIcon (the - // small downward-facing arrow on the right side of the container), so that clicks on both - // views will open the dialog. - holder.summary.setOnClickListener(onClick) - holder.summaryHolder.setEndIconOnClickListener(onClick) - - displayValidationResult(questionnaireViewItem.validationResult) - } - - private fun displayValidationResult(validationResult: ValidationResult) { - holder.summaryHolder.error = - getValidationErrorMessage( - holder.summaryHolder.context, + Column( + modifier = + Modifier.fillMaxWidth() + .padding( + horizontal = dimensionResource(R.dimen.item_margin_horizontal), + vertical = dimensionResource(R.dimen.item_margin_vertical), + ), + ) { + Header( questionnaireViewItem, - validationResult, ) - } - - override fun setReadOnly(isReadOnly: Boolean) { - holder.summaryHolder.isEnabled = !isReadOnly - } - - private fun cleanupOldState() { - selectedOptionsJob?.cancel() - } - - private suspend fun updateAnswers(selectedOptions: SelectedOptions) { - questionnaireViewItem.clearAnswer() - var answers = arrayOf() - selectedOptions.options - .filter { it.selected } - .map { option -> - answers += - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { - value = option.item.value - } - } - selectedOptions.otherOptions.map { otherOption -> - answers += - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { - value = StringType(otherOption) + questionnaireViewItem.questionnaireItem.itemMedia?.let { MediaItem(it) } + + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { + OutlinedTextField( + value = selectedOptionsString, + onValueChange = {}, + readOnly = true, + modifier = + Modifier.fillMaxWidth() + .testTag(MULTI_SELECT_TEXT_FIELD_TAG) + .semantics { if (hasValidationError) error(supportingHelperText ?: "") } + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable, !readOnly), + label = { hintLabelText?.let { Text(it) } }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + supportingText = { + if (!supportingHelperText.isNullOrBlank()) { + Text(supportingHelperText) + } + }, + isError = hasValidationError, + enabled = !readOnly, + ) + + if (expanded) { + OptionDialogSelect( + context = context, + title = dialogTitle, + multiSelect = isMultiSelect, + otherOptionsAllowed = allowOtherOptions, + selectedOptions = selectedOptions, + onDismiss = { expanded = false }, + onConfirm = { newOptions -> + selectedOptions = newOptions + coroutineScope.launch { + val optionAnswers = + newOptions.options + .filter { it.selected } + .map { + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = it.item.value + } + } + val otherOptionAnswers = + newOptions.otherOptions.map { + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = StringType(it) + } + } + + val answersArray = (optionAnswers + otherOptionAnswers).toTypedArray() + + questionnaireViewItem.setAnswer(*answersArray) + } + }, + ) } + } } - questionnaireViewItem.setAnswer(*answers) } } - - private class DialogSelectViewHolder(itemView: View) { - val header: HeaderView = itemView.findViewById(R.id.header) - val summary: TextView = itemView.findViewById(R.id.multi_select_summary) - val summaryHolder: TextInputLayout = itemView.findViewById(R.id.multi_select_summary_holder) - } -} - -internal class QuestionnaireItemDialogSelectViewModel : ViewModel() { - private val linkIdsToSelectedOptionsFlow = - mutableMapOf>() - - fun getSelectedOptionsFlow(linkId: String): Flow = selectedOptionsFlow(linkId) - - suspend fun updateSelectedOptions(linkId: String, selectedOptions: SelectedOptions) { - selectedOptionsFlow(linkId).emit(selectedOptions) - } - - private fun selectedOptionsFlow(linkId: String) = - linkIdsToSelectedOptionsFlow.getOrPut(linkId) { MutableSharedFlow(replay = 0) } -} - -data class SelectedOptions( - val options: List, - val otherOptions: List, -) { - val selectedSummary: String = - (options.filter { it.selected }.map { it.displayString } + otherOptions).joinToString() -} - -/** Represents selectable options in the multi-select page. */ -data class OptionSelectOption( - val item: Questionnaire.QuestionnaireItemAnswerOptionComponent, - val selected: Boolean, - val context: Context, -) { - val displayString: String = item.value.displayString(context) } private fun QuestionnaireViewItem.extractInitialOptions(context: Context): SelectedOptions { @@ -211,9 +202,4 @@ private fun QuestionnaireViewItem.extractInitialOptions(context: Context): Selec ) } -private fun Questionnaire.QuestionnaireItemComponent.buildConfig() = - OptionSelectDialogFragment.Config( - multiSelect = repeats, - // Client had to specify that they want an open-choice control to use "Other" options - otherOptionsAllowed = itemControl == ItemControlTypes.OPEN_CHOICE, - ) +internal const val MULTI_SELECT_TEXT_FIELD_TAG = "multi_select_summary_holder" diff --git a/datacapture/src/main/res/drawable/arrow_drop_down_24px.xml b/datacapture/src/main/res/drawable/arrow_drop_down_24px.xml new file mode 100644 index 0000000000..b3a647d8d2 --- /dev/null +++ b/datacapture/src/main/res/drawable/arrow_drop_down_24px.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/datacapture/src/main/res/layout/multi_select_dialog.xml b/datacapture/src/main/res/layout/multi_select_dialog.xml deleted file mode 100644 index c126d9e41c..0000000000 --- a/datacapture/src/main/res/layout/multi_select_dialog.xml +++ /dev/null @@ -1,83 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/datacapture/src/main/res/layout/option_item_multi.xml b/datacapture/src/main/res/layout/option_item_multi.xml deleted file mode 100644 index 078d73517c..0000000000 --- a/datacapture/src/main/res/layout/option_item_multi.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - diff --git a/datacapture/src/main/res/layout/option_item_other_add_another.xml b/datacapture/src/main/res/layout/option_item_other_add_another.xml deleted file mode 100644 index 0d7e5b153b..0000000000 --- a/datacapture/src/main/res/layout/option_item_other_add_another.xml +++ /dev/null @@ -1,22 +0,0 @@ - - -