diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/DisplayViewHolderFactoryTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DisplayViewHolderFactoryTest.kt similarity index 53% rename from datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/DisplayViewHolderFactoryTest.kt rename to datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DisplayViewHolderFactoryTest.kt index 4a1e7c1ac3..6f998969d1 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/DisplayViewHolderFactoryTest.kt +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DisplayViewHolderFactoryTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,31 +14,50 @@ * limitations under the License. */ -package com.google.android.fhir.datacapture.views.factories +package com.google.android.fhir.datacapture.test.views -import android.view.View import android.widget.FrameLayout import android.widget.TextView +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onNodeWithTag +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.test.TestActivity import com.google.android.fhir.datacapture.validation.NotValidated 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.factories.DisplayViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder import com.google.common.truth.Truth.assertThat import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.junit.Before +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.RuntimeEnvironment -@RunWith(RobolectricTestRunner::class) +@RunWith(AndroidJUnit4::class) class DisplayViewHolderFactoryTest { - private val parent = - FrameLayout( - RuntimeEnvironment.getApplication().apply { - setTheme(com.google.android.material.R.style.Theme_Material3_DayNight) - }, - ) - private val viewHolder = DisplayViewHolderFactory.create(parent) + @get:Rule + val activityScenarioRule: ActivityScenarioRule = + ActivityScenarioRule(TestActivity::class.java) + + @get:Rule val composeTestRule = createEmptyComposeRule() + + private lateinit var viewHolder: QuestionnaireItemViewHolder + + @Before + fun setUp() { + activityScenarioRule.scenario.onActivity { activity -> + viewHolder = DisplayViewHolderFactory.create(FrameLayout(activity)) + activity.setContentView(viewHolder.itemView) + } + + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + } @Test fun shouldSetQuestionHeader() { @@ -51,12 +70,15 @@ class DisplayViewHolderFactoryTest { ), ) + // Synchronize + composeTestRule.waitForIdle() + assertThat(viewHolder.itemView.findViewById(R.id.question).text.toString()) .isEqualTo("Display") } @Test - fun `hides error textview in the header`() { + fun hidesErrorTextviewInTheHeader() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), @@ -65,8 +87,9 @@ class DisplayViewHolderFactoryTest { answersChangedCallback = { _, _, _, _ -> }, ), ) - - assertThat(viewHolder.itemView.findViewById(R.id.error_text_at_header).visibility) - .isEqualTo(View.GONE) + composeTestRule + .onNodeWithTag(ERROR_TEXT_AT_HEADER_TEST_TAG) + .assertIsNotDisplayed() + .assertDoesNotExist() } } diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/QuantityViewHolderFactoryEspressoTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/QuantityViewHolderFactoryEspressoTest.kt new file mode 100644 index 0000000000..ed52a02624 --- /dev/null +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/QuantityViewHolderFactoryEspressoTest.kt @@ -0,0 +1,225 @@ +/* + * 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.view.View +import android.widget.FrameLayout +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isPopup +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.platform.app.InstrumentationRegistry +import com.google.android.fhir.datacapture.test.TestActivity +import com.google.android.fhir.datacapture.validation.NotValidated +import com.google.android.fhir.datacapture.views.QuestionnaireViewItem +import com.google.android.fhir.datacapture.views.compose.DROP_DOWN_TEXT_FIELD_TAG +import com.google.android.fhir.datacapture.views.compose.EDIT_TEXT_FIELD_TEST_TAG +import com.google.android.fhir.datacapture.views.factories.QuantityViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder +import com.google.common.truth.Truth.assertThat +import java.math.BigDecimal +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 + +class QuantityViewHolderFactoryEspressoTest { + @get:Rule + val activityScenarioRule: ActivityScenarioRule = + ActivityScenarioRule(TestActivity::class.java) + + @get:Rule val composeTestRule = createEmptyComposeRule() + + private lateinit var parent: FrameLayout + private lateinit var viewHolder: QuestionnaireItemViewHolder + + @Before + fun setup() { + activityScenarioRule.scenario.onActivity { activity -> parent = FrameLayout(activity) } + viewHolder = QuantityViewHolderFactory.create(parent) + setTestLayout(viewHolder.itemView) + } + + @Test + fun shouldSetDraftWithUnit() { + var answerHolder: List? = null + var draftHolder: Any? = null + + val questionnaireViewItem = createQuestionnaireViewItem { answers, draft -> + answerHolder = answers + draftHolder = draft + } + + runOnUI { viewHolder.bind(questionnaireViewItem) } + composeTestRule.onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() + composeTestRule + .onNode(hasText("centimeter") and hasAnyAncestor(isPopup())) + .assertIsDisplayed() + .performClick() + composeTestRule.onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).assertTextEquals("centimeter") + + composeTestRule.waitUntil { draftHolder != null } + + with(draftHolder as Coding) { + assertThat(system).isEqualTo("http://unitofmeasure.com") + assertThat(code).isEqualTo("cm") + assertThat(display).isEqualTo("centimeter") + } + assertThat(answerHolder).isEmpty() + } + + @Test + fun shouldSetDraftWithDecimalValue() { + var answerHolder: List? = null + var draftHolder: Any? = null + + val questionnaireViewItem = createQuestionnaireViewItem { answers, draft -> + answerHolder = answers + draftHolder = draft + } + + runOnUI { viewHolder.bind(questionnaireViewItem) } + + composeTestRule.onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).performClick().performTextInput("22") + composeTestRule.waitUntil { draftHolder != null } + composeTestRule.onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertTextEquals("22") + + assertThat(draftHolder as BigDecimal).isEqualTo(BigDecimal(22)) + assertThat(answerHolder).isEmpty() + } + + @Test + fun draftWithUnit_shouldCompleteQuantity() { + var answerHolder: List? = null + var draftHolder: Any? = null + + val questionnaireViewItem = + createQuestionnaireViewItem(Coding("http://unitofmeasure.com", "cm", "centimeter")) { + answers, + draft, + -> + answerHolder = answers + draftHolder = draft + } + + runOnUI { viewHolder.bind(questionnaireViewItem) } + composeTestRule.onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() + composeTestRule.onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).performClick().performTextInput("22") + + composeTestRule.waitUntil { !answerHolder.isNullOrEmpty() } + + composeTestRule.onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertTextEquals("22") + + with(answerHolder!!.single().valueQuantity) { + assertThat(system).isEqualTo("http://unitofmeasure.com") + assertThat(code).isEqualTo("cm") + assertThat(unit).isEqualTo("centimeter") + assertThat(value).isEqualTo(BigDecimal("22.0")) + } + assertThat(draftHolder).isNull() + } + + @Test + fun draftWithDecimalValue_shouldCompleteQuantity() { + var answerHolder: List? = null + var draftHolder: Any? = null + + val questionnaireViewItem = + createQuestionnaireViewItem(BigDecimal(22)) { answers, draft -> + answerHolder = answers + draftHolder = draft + } + + runOnUI { viewHolder.bind(questionnaireViewItem) } + composeTestRule.onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() + composeTestRule + .onNode(hasText("centimeter") and hasAnyAncestor(isPopup())) + .assertIsDisplayed() + .performClick() + composeTestRule.onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).assertTextEquals("centimeter") + + composeTestRule.waitUntil { !answerHolder.isNullOrEmpty() } + + with(answerHolder!!.single().valueQuantity) { + assertThat(system).isEqualTo("http://unitofmeasure.com") + assertThat(code).isEqualTo("cm") + assertThat(unit).isEqualTo("centimeter") + assertThat(value).isEqualTo(BigDecimal("22.0")) + } + assertThat(draftHolder).isNull() + } + + private fun createQuestionnaireViewItem( + draftAnswer: Any? = null, + answersChangedCallback: + (List, Any?) -> Unit, + ): QuestionnaireViewItem { + return QuestionnaireViewItem( + Questionnaire.QuestionnaireItemComponent().apply { + required = true + addExtension( + Extension().apply { + url = "http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption" + setValue( + Coding().apply { + code = "cm" + system = "http://unitofmeasure.com" + display = "centimeter" + }, + ) + }, + ) + addExtension( + Extension().apply { + url = "http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption" + setValue( + Coding().apply { + code = "[in_i]" + system = "http://unitofmeasure.com" + display = "inch" + }, + ) + }, + ) + }, + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, draft -> answersChangedCallback(answers, draft) }, + draftAnswer = draftAnswer, + ) + } + + /** Method to run code snippet on UI/main thread */ + private fun runOnUI(action: () -> Unit) { + activityScenarioRule.scenario.onActivity { action() } + } + + /** Method to set content view for test activity */ + private fun setTestLayout(view: View) { + activityScenarioRule.scenario.onActivity { activity -> activity.setContentView(view) } + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + } +} diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/QuantityViewHolderFactoryTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/QuantityViewHolderFactoryTest.kt similarity index 67% rename from datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/QuantityViewHolderFactoryTest.kt rename to datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/QuantityViewHolderFactoryTest.kt index f3fd25aa7d..06225312b9 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/QuantityViewHolderFactoryTest.kt +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/QuantityViewHolderFactoryTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 Google LLC + * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,43 +14,66 @@ * limitations under the License. */ -package com.google.android.fhir.datacapture.views.factories +package com.google.android.fhir.datacapture.test.views -import android.view.View -import android.widget.AutoCompleteTextView import android.widget.FrameLayout import android.widget.TextView -import androidx.appcompat.app.AppCompatActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +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.test.TestActivity import com.google.android.fhir.datacapture.validation.Invalid import com.google.android.fhir.datacapture.validation.NotValidated import com.google.android.fhir.datacapture.validation.Valid import com.google.android.fhir.datacapture.views.QuestionTextConfiguration import com.google.android.fhir.datacapture.views.QuestionnaireViewItem -import com.google.android.material.textfield.TextInputEditText -import com.google.android.material.textfield.TextInputLayout -import com.google.common.truth.Truth.assertThat +import com.google.android.fhir.datacapture.views.compose.DROP_DOWN_TEXT_FIELD_TAG +import com.google.android.fhir.datacapture.views.compose.EDIT_TEXT_FIELD_TEST_TAG +import com.google.android.fhir.datacapture.views.compose.ERROR_TEXT_AT_HEADER_TEST_TAG +import com.google.android.fhir.datacapture.views.factories.QuantityViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder +import com.google.common.truth.Truth import java.math.BigDecimal import org.hl7.fhir.r4.model.Quantity import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.junit.Before +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.robolectric.Robolectric -import org.robolectric.RobolectricTestRunner -@RunWith(RobolectricTestRunner::class) +@RunWith(AndroidJUnit4::class) class QuantityViewHolderFactoryTest { - private val parent = - FrameLayout( - Robolectric.buildActivity(AppCompatActivity::class.java).create().get().apply { - setTheme(com.google.android.material.R.style.Theme_Material3_DayNight) - }, - ) - private val viewHolder = QuantityViewHolderFactory.create(parent) + + @get:Rule + val activityScenarioRule: ActivityScenarioRule = + ActivityScenarioRule(TestActivity::class.java) + + @get:Rule val composeTestRule = createEmptyComposeRule() + + private lateinit var viewHolder: QuestionnaireItemViewHolder + + @Before + fun setUp() { + activityScenarioRule.scenario.onActivity { activity -> + viewHolder = QuantityViewHolderFactory.create(FrameLayout(activity)) + activity.setContentView(viewHolder.itemView) + } + + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + } @Test - fun `should set question text`() { + fun shouldSetQuestionText() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { text = "Question?" }, @@ -60,12 +83,15 @@ class QuantityViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.question).text.toString()) + // Synchronize + composeTestRule.waitForIdle() + + Truth.assertThat(viewHolder.itemView.findViewById(R.id.question).text.toString()) .isEqualTo("Question?") } @Test - fun `should set input decimal value`() { + fun shouldSetInputDecimalValue() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), @@ -81,17 +107,11 @@ class QuantityViewHolderFactoryTest { ), ) - assertThat( - viewHolder.itemView - .findViewById(R.id.text_input_edit_text) - .text - .toString(), - ) - .isEqualTo("5") + composeTestRule.onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertTextEquals("5") } @Test - fun `should clear input decimal value`() { + fun shouldClearInputDecimalValue() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), @@ -106,6 +126,8 @@ class QuantityViewHolderFactoryTest { answersChangedCallback = { _, _, _, _ -> }, ), ) + composeTestRule.onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertTextEquals("5") + viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), @@ -114,18 +136,11 @@ class QuantityViewHolderFactoryTest { answersChangedCallback = { _, _, _, _ -> }, ), ) - - assertThat( - viewHolder.itemView - .findViewById(R.id.text_input_edit_text) - .text - .toString(), - ) - .isEqualTo("") + composeTestRule.onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertTextEquals("") } @Test - fun `should set unit value`() { + fun shouldSetUnitValue() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), @@ -141,17 +156,11 @@ class QuantityViewHolderFactoryTest { ), ) - assertThat( - viewHolder.itemView - .findViewById(R.id.unit_auto_complete) - .text - .toString(), - ) - .isEqualTo("kg") + composeTestRule.onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).assertTextEquals("kg") } @Test - fun `should set unit value from initial when answer is missing`() { + fun shouldSetUnitValueFromInitialWhenAnswerIsMissing() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -170,17 +179,11 @@ class QuantityViewHolderFactoryTest { ), ) - assertThat( - viewHolder.itemView - .findViewById(R.id.unit_auto_complete) - .text - .toString(), - ) - .isEqualTo("kg") + composeTestRule.onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).assertTextEquals("kg") } @Test - fun `should clear unit value`() { + fun shouldClearUnitValue() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), @@ -195,6 +198,9 @@ class QuantityViewHolderFactoryTest { answersChangedCallback = { _, _, _, _ -> }, ), ) + + composeTestRule.onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).assertTextEquals("kg") + viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), @@ -204,17 +210,11 @@ class QuantityViewHolderFactoryTest { ), ) - assertThat( - viewHolder.itemView - .findViewById(R.id.unit_auto_complete) - .text - .toString(), - ) - .isEqualTo("") + composeTestRule.onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).assertTextEquals("") } @Test - fun `should display error message in validation result`() { + fun shouldDisplayErrorMessageInValidationResult() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { required = true }, @@ -224,12 +224,12 @@ class QuantityViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.text_input_layout).error) - .isEqualTo("Missing answer for required field.") + composeTestRule.onNodeWithContentDescription("Error").assertIsDisplayed() + composeTestRule.onNodeWithText("Missing answer for required field.").assertIsDisplayed() } @Test - fun `should display no error message when validation result is valid`() { + fun shouldDisplayNoErrorMessageWhenValidationResultIsValid() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { required = true }, @@ -245,12 +245,11 @@ class QuantityViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.text_input_layout).error) - .isNull() + composeTestRule.onNodeWithContentDescription("Error").assertDoesNotExist() } @Test - fun `should disable text input in read-only mode`() { + fun shouldDisableTextInputInReadOnlyMode() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { readOnly = true }, @@ -260,14 +259,11 @@ class QuantityViewHolderFactoryTest { ), ) - assertThat( - viewHolder.itemView.findViewById(R.id.text_input_edit_text).isEnabled, - ) - .isFalse() + composeTestRule.onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertIsNotEnabled() } @Test - fun `should disable unit input in read-only mode`() { + fun shouldDisableUnitInputInReadOnlyMode() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { readOnly = true }, @@ -277,14 +273,11 @@ class QuantityViewHolderFactoryTest { ), ) - assertThat( - viewHolder.itemView.findViewById(R.id.unit_auto_complete).isEnabled, - ) - .isFalse() + composeTestRule.onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).assertIsNotEnabled() } @Test - fun `should always hide error textview in the header`() { + fun shouldAlwaysHideErrorTextviewInTheHeader() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), @@ -294,12 +287,14 @@ class QuantityViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.error_text_at_header).visibility) - .isEqualTo(View.GONE) + composeTestRule + .onNodeWithTag(ERROR_TEXT_AT_HEADER_TEST_TAG) + .assertIsNotDisplayed() + .assertDoesNotExist() } @Test - fun `should show asterisk`() { + fun shouldShowAsterisk() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -312,13 +307,15 @@ class QuantityViewHolderFactoryTest { questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = true), ), ) + // Synchronize + composeTestRule.waitForIdle() - assertThat(viewHolder.itemView.findViewById(R.id.question).text.toString()) + Truth.assertThat(viewHolder.itemView.findViewById(R.id.question).text.toString()) .isEqualTo("Question? *") } @Test - fun `should hide asterisk`() { + fun shouldHideAsterisk() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -332,12 +329,15 @@ class QuantityViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.question).text.toString()) + // Synchronize + composeTestRule.waitForIdle() + + Truth.assertThat(viewHolder.itemView.findViewById(R.id.question).text.toString()) .isEqualTo("Question?") } @Test - fun `should show required text`() { + fun shouldShowRequiredText() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { required = true }, @@ -348,17 +348,11 @@ class QuantityViewHolderFactoryTest { ), ) - assertThat( - viewHolder.itemView - .findViewById(R.id.text_input_layout) - .helperText - .toString(), - ) - .isEqualTo("Required") + composeTestRule.onNodeWithText("Required").assertIsDisplayed() } @Test - fun `should hide required text`() { + fun shouldHideRequiredText() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { required = true }, @@ -369,12 +363,11 @@ class QuantityViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.text_input_layout).helperText) - .isNull() + composeTestRule.onNodeWithText("Required").assertDoesNotExist() } @Test - fun `should show optional text`() { + fun shouldShowOptionalText() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), @@ -385,17 +378,11 @@ class QuantityViewHolderFactoryTest { ), ) - assertThat( - viewHolder.itemView - .findViewById(R.id.text_input_layout) - .helperText - .toString(), - ) - .isEqualTo("Optional") + composeTestRule.onNodeWithText("Optional").assertIsDisplayed() } @Test - fun `should hide optional text`() { + fun shouldHideOptionalText() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), @@ -406,7 +393,6 @@ class QuantityViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.text_input_layout).helperText) - .isNull() + composeTestRule.onNodeWithText("Optional").assertDoesNotExist() } } diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/QuestionnaireItemQuantityViewHolderFactoryEspressoTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/QuestionnaireItemQuantityViewHolderFactoryEspressoTest.kt deleted file mode 100644 index 7f5bab0695..0000000000 --- a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/QuestionnaireItemQuantityViewHolderFactoryEspressoTest.kt +++ /dev/null @@ -1,324 +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.view.View -import android.widget.AutoCompleteTextView -import android.widget.FrameLayout -import android.widget.TextView -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions.click -import androidx.test.espresso.action.ViewActions.typeText -import androidx.test.espresso.assertion.ViewAssertions -import androidx.test.espresso.matcher.RootMatchers -import androidx.test.espresso.matcher.ViewMatchers -import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.ext.junit.rules.ActivityScenarioRule -import androidx.test.platform.app.InstrumentationRegistry -import com.google.android.fhir.datacapture.R -import com.google.android.fhir.datacapture.test.TestActivity -import com.google.android.fhir.datacapture.test.utilities.delayMainThread -import com.google.android.fhir.datacapture.validation.NotValidated -import com.google.android.fhir.datacapture.views.QuestionnaireViewItem -import com.google.android.fhir.datacapture.views.factories.QuantityViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder -import com.google.common.truth.Truth.assertThat -import java.math.BigDecimal -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 - -class QuestionnaireItemQuantityViewHolderFactoryEspressoTest { - @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 = QuantityViewHolderFactory.create(parent) - setTestLayout(viewHolder.itemView) - } - - @Test - fun shouldSetDraftWithUnit() { - var answerHolder: List? = null - var draftHolder: Any? = null - - val questionnaireViewItem = - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent().apply { - required = true - addExtension( - Extension().apply { - url = "http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption" - setValue( - Coding().apply { - code = "cm" - system = "http://unitofmeasure.com" - display = "centimeter" - }, - ) - }, - ) - addExtension( - Extension().apply { - url = "http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption" - setValue( - Coding().apply { - code = "[in_i]" - system = "http://unitofmeasure.com" - display = "inch" - }, - ) - }, - ) - }, - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, answers, draft -> - answerHolder = answers - draftHolder = draft - }, - ) - - runOnUI { - viewHolder.bind(questionnaireViewItem) - viewHolder.itemView.findViewById(R.id.unit_auto_complete).showDropDown() - } - - onView(withId(R.id.unit_auto_complete)).perform(delayMainThread()) - onView(ViewMatchers.withText("centimeter")) - .inRoot(RootMatchers.isPlatformPopup()) - .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) - .perform(click()) - - assertThat(viewHolder.itemView.findViewById(R.id.unit_auto_complete).text.toString()) - .isEqualTo("centimeter") - - with(draftHolder as Coding) { - assertThat(system).isEqualTo("http://unitofmeasure.com") - assertThat(code).isEqualTo("cm") - assertThat(display).isEqualTo("centimeter") - } - assertThat(answerHolder).isEmpty() - } - - @Test - fun shouldSetDraftWithDecimalValue() { - var answerHolder: List? = null - var draftHolder: Any? = null - - val questionnaireViewItem = - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent().apply { - required = true - addExtension( - Extension().apply { - url = "http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption" - setValue( - Coding().apply { - code = "cm" - system = "http://unitofmeasure.com" - display = "centimeter" - }, - ) - }, - ) - addExtension( - Extension().apply { - url = "http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption" - setValue( - Coding().apply { - code = "[in_i]" - system = "http://unitofmeasure.com" - display = "inch" - }, - ) - }, - ) - }, - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, answers, draft -> - answerHolder = answers - draftHolder = draft - }, - ) - - runOnUI { viewHolder.bind(questionnaireViewItem) } - - onView(withId(R.id.text_input_edit_text)).perform(click()) - onView(withId(R.id.text_input_edit_text)).perform(typeText("22")) - - assertThat( - viewHolder.itemView.findViewById(R.id.text_input_edit_text).text.toString(), - ) - .isEqualTo("22") - - assertThat(draftHolder as BigDecimal).isEqualTo(BigDecimal(22)) - assertThat(answerHolder).isEmpty() - } - - @Test - fun draftWithUnit_shouldCompleteQuantity() { - var answerHolder: List? = null - var draftHolder: Any? = null - - val questionnaireViewItem = - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent().apply { - required = true - addExtension( - Extension().apply { - url = "http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption" - setValue( - Coding().apply { - code = "cm" - system = "http://unitofmeasure.com" - display = "centimeter" - }, - ) - }, - ) - addExtension( - Extension().apply { - url = "http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption" - setValue( - Coding().apply { - code = "[in_i]" - system = "http://unitofmeasure.com" - display = "inch" - }, - ) - }, - ) - }, - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, answers, draft -> - answerHolder = answers - draftHolder = draft - }, - draftAnswer = Coding("http://unitofmeasure.com", "cm", "centimeter"), - ) - - runOnUI { - viewHolder.bind(questionnaireViewItem) - viewHolder.itemView.findViewById(R.id.unit_auto_complete).showDropDown() - } - - onView(withId(R.id.text_input_edit_text)).perform(click()) - onView(withId(R.id.text_input_edit_text)).perform(typeText("22")) - assertThat( - viewHolder.itemView.findViewById(R.id.text_input_edit_text).text.toString(), - ) - .isEqualTo("22") - - with(answerHolder!!.single().valueQuantity) { - assertThat(system).isEqualTo("http://unitofmeasure.com") - assertThat(code).isEqualTo("cm") - assertThat(unit).isEqualTo("centimeter") - assertThat(value).isEqualTo(BigDecimal("22.0")) - } - assertThat(draftHolder).isNull() - } - - @Test - fun draftWithDecimalValue_shouldCompleteQuantity() { - var answerHolder: List? = null - var draftHolder: Any? = null - - val questionnaireViewItem = - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent().apply { - required = true - addExtension( - Extension().apply { - url = "http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption" - setValue( - Coding().apply { - code = "cm" - system = "http://unitofmeasure.com" - display = "centimeter" - }, - ) - }, - ) - addExtension( - Extension().apply { - url = "http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption" - setValue( - Coding().apply { - code = "[in_i]" - system = "http://unitofmeasure.com" - display = "inch" - }, - ) - }, - ) - }, - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, answers, draft -> - answerHolder = answers - draftHolder = draft - }, - draftAnswer = BigDecimal(22), - ) - - runOnUI { - viewHolder.bind(questionnaireViewItem) - viewHolder.itemView.findViewById(R.id.unit_auto_complete).showDropDown() - } - - onView(withId(R.id.unit_auto_complete)).perform(delayMainThread()) - onView(ViewMatchers.withText("centimeter")) - .inRoot(RootMatchers.isPlatformPopup()) - .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) - .perform(click()) - assertThat(viewHolder.itemView.findViewById(R.id.unit_auto_complete).text.toString()) - .isEqualTo("centimeter") - - with(answerHolder!!.single().valueQuantity) { - assertThat(system).isEqualTo("http://unitofmeasure.com") - assertThat(code).isEqualTo("cm") - assertThat(unit).isEqualTo("centimeter") - assertThat(value).isEqualTo(BigDecimal("22.0")) - } - assertThat(draftHolder).isNull() - } - - /** Method to run code snippet on UI/main thread */ - private fun runOnUI(action: () -> Unit) { - activityScenarioRule.scenario.onActivity { action() } - } - - /** Method to set content view for test activity */ - private fun setTestLayout(view: View) { - activityScenarioRule.scenario.onActivity { activity -> activity.setContentView(view) } - InstrumentationRegistry.getInstrumentation().waitForIdleSync() - } -} diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/compose/ExposedDropDownMenuBoxItemTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/compose/ExposedDropDownMenuBoxItemTest.kt new file mode 100644 index 0000000000..2e5efd1c2f --- /dev/null +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/compose/ExposedDropDownMenuBoxItemTest.kt @@ -0,0 +1,67 @@ +/* + * 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.test.views.compose + +import android.content.Context +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.core.content.ContextCompat +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.android.fhir.datacapture.R +import com.google.android.fhir.datacapture.views.compose.DROP_DOWN_TEXT_FIELD_TAG +import com.google.android.fhir.datacapture.views.compose.ExposedDropDownMenuBoxItem +import com.google.android.fhir.datacapture.views.factories.DropDownAnswerOption +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ExposedDropDownMenuBoxItemTest { + + @get:Rule val composeTestRule = createComposeRule() + + val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun shouldShowLeadingIconForDropDownOptions() { + val testDropDownAnswerOption = + DropDownAnswerOption( + answerId = "", + answerOptionString = "Test Option", + answerOptionImage = ContextCompat.getDrawable(context, R.drawable.ic_image_file), + ) + + composeTestRule.setContent { + ExposedDropDownMenuBoxItem( + modifier = Modifier, + enabled = true, + options = listOf(testDropDownAnswerOption), + onDropDownAnswerOptionSelected = {}, + ) + } + + composeTestRule.onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() + composeTestRule + .onNodeWithContentDescription(testDropDownAnswerOption.answerOptionString) + .assertIsDisplayed() + } +} diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionItemStyle.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionItemStyle.kt index 3067d969bd..161e5b4cb4 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionItemStyle.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionItemStyle.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 Google LLC + * Copyright 2024-2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -29,8 +29,7 @@ import com.google.android.fhir.datacapture.R * * If the custom style resource name is valid, it applies the custom style to the view. If the * custom style resource name is not valid or not found, it falls back to applying the default style - * defined by the given style resource ID. It sets the view's tag to resourceId to indicate that the - * custom style has been applied. + * defined by the given style resource ID. * * @param context the context used to access resources. * @param view the view to which the style should be applied. @@ -46,7 +45,6 @@ internal fun applyCustomOrDefaultStyle( val customStyleResId = customStyleName?.let { getStyleResIdByName(context, it) } ?: 0 when { customStyleResId != 0 -> { - view.tag = customStyleResId QuestionItemCustomStyle().applyStyle(context, view, customStyleResId) } defaultStyleResId != 0 -> { @@ -58,11 +56,6 @@ internal fun applyCustomOrDefaultStyle( /** * Applies the default style to the given view if the default style has not already been applied. * - * This function checks the `view`'s tag to determine if a style has been previously applied. If the - * tag is an integer, it will apply the default style specified by `defaultStyleResId`. After - * applying the style, it resets the view's tag to `null` to indicate that the default style has - * been applied. - * * @param context The context used to access resources and themes. * @param view The view to which the default style will be applied. * @param defaultStyleResId The resource ID of the default style to apply. @@ -72,10 +65,7 @@ private fun applyDefaultStyleIfNotApplied( view: View, defaultStyleResId: Int, ) { - (view.tag as? Int)?.let { - QuestionItemDefaultStyle().applyStyle(context, view, defaultStyleResId) - view.tag = null - } + QuestionItemDefaultStyle().applyStyle(context, view, defaultStyleResId) } /** diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/EditTextFieldItem.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/EditTextFieldItem.kt index 57cd4ecb57..b7cdc984ce 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/EditTextFieldItem.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/EditTextFieldItem.kt @@ -23,7 +23,6 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -119,7 +118,6 @@ internal fun OutlinedEditTextFieldItem( label = { hint?.let { Text(it) } }, supportingText = { helperText?.let { Text(it) } }, isError = isError, - colors = OutlinedTextFieldDefaults.colors(), trailingIcon = { if (isError) { Icon(painter = painterResource(R.drawable.error_24px), contentDescription = "Error") diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/ExposedDropDownMenuBoxItem.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/ExposedDropDownMenuBoxItem.kt new file mode 100644 index 0000000000..03e72eb50e --- /dev/null +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/ExposedDropDownMenuBoxItem.kt @@ -0,0 +1,106 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.datacapture.views.compose + +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.testTag +import androidx.core.graphics.drawable.toBitmap +import com.google.android.fhir.datacapture.views.factories.DropDownAnswerOption + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ExposedDropDownMenuBoxItem( + modifier: Modifier, + enabled: Boolean, + selectedOption: DropDownAnswerOption? = null, + options: List, + onDropDownAnswerOptionSelected: (DropDownAnswerOption?) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + var selectedDropDownAnswerOption by + remember(selectedOption, options) { mutableStateOf(selectedOption) } + val selectedOptionDisplay by + remember(selectedDropDownAnswerOption) { + derivedStateOf { selectedDropDownAnswerOption?.answerOptionString ?: "" } + } + + LaunchedEffect(selectedDropDownAnswerOption) { + onDropDownAnswerOptionSelected(selectedDropDownAnswerOption) + } + + ExposedDropdownMenuBox( + modifier = modifier, + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + OutlinedTextField( + value = selectedOptionDisplay, + onValueChange = {}, + modifier = + Modifier.testTag(DROP_DOWN_TEXT_FIELD_TAG) + .menuAnchor(MenuAnchorType.PrimaryNotEditable, enabled), + readOnly = true, + enabled = enabled, + minLines = 1, + label = {}, + supportingText = {}, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + options.forEach { option -> + DropdownMenuItem( + text = { + Text(option.answerOptionAnnotatedString(), style = MaterialTheme.typography.bodyLarge) + }, + leadingIcon = { + option.answerOptionImage?.let { + Icon( + it.toBitmap().asImageBitmap(), + contentDescription = option.answerOptionString, + ) + } + }, + enabled = enabled, + onClick = { + selectedDropDownAnswerOption = option + expanded = false + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + ) + } + } + } +} + +const val DROP_DOWN_TEXT_FIELD_TAG = "drop_down_text_field" diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/Header.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/Header.kt index 481913a4c1..b889492688 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/Header.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/Header.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -43,9 +44,9 @@ 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.unit.dp import androidx.compose.ui.viewinterop.AndroidView import com.google.android.fhir.datacapture.R +import com.google.android.fhir.datacapture.extensions.QuestionItemDefaultStyle import com.google.android.fhir.datacapture.extensions.StyleUrl import com.google.android.fhir.datacapture.extensions.appendAsteriskToQuestionText import com.google.android.fhir.datacapture.extensions.applyCustomOrDefaultStyle @@ -254,7 +255,8 @@ internal fun Help( helpButtonOnClick(isCardOpen) }, modifier = - Modifier.padding(start = dimensionResource(R.dimen.help_button_margin_start)) + Modifier.padding(dimensionResource(R.dimen.help_icon_padding)) + .padding(start = dimensionResource(R.dimen.help_button_margin_start)) .testTag(HELP_BUTTON_TAG) .size( width = dimensionResource(R.dimen.help_button_width), @@ -270,12 +272,22 @@ internal fun Help( } if (isCardOpen) { - Card(modifier = Modifier.padding(top = 8.dp).testTag(HELP_CARD_TAG)) { - Column(modifier = Modifier.padding(8.dp)) { + Card( + modifier = + Modifier.padding(top = dimensionResource(R.dimen.help_card_margin_top)) + .testTag(HELP_CARD_TAG), + colors = + CardDefaults.cardColors().copy(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column { Text( text = stringResource(id = R.string.help), modifier = - Modifier.padding(horizontal = dimensionResource(R.dimen.help_header_margin_horizontal)), + Modifier.padding(horizontal = dimensionResource(R.dimen.help_header_margin_horizontal)) + .padding( + top = dimensionResource(R.dimen.help_header_margin_top), + bottom = dimensionResource(R.dimen.help_header_margin_bottom), + ), style = MaterialTheme.typography.titleSmall, ) @@ -284,10 +296,18 @@ internal fun Help( TextView(it).apply { id = R.id.helpText movementMethod = LinkMovementMethod.getInstance() + + QuestionItemDefaultStyle() + .applyStyle( + context, + this, + getStyleResIdFromAttribute(it, R.attr.questionnaireHelpTextStyle), + ) } }, modifier = - Modifier.padding(horizontal = dimensionResource(R.dimen.help_text_margin_horizontal)), + Modifier.padding(horizontal = dimensionResource(R.dimen.help_text_margin_horizontal)) + .padding(bottom = dimensionResource(R.dimen.help_text_margin_bottom)), update = { it.text = helpCardLocalizedText }, ) } diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DisplayViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DisplayViewHolderFactory.kt index 47610a6cee..8735d2e10a 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DisplayViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DisplayViewHolderFactory.kt @@ -16,28 +16,35 @@ package com.google.android.fhir.datacapture.views.factories -import android.view.View +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource import com.google.android.fhir.datacapture.R -import com.google.android.fhir.datacapture.views.HeaderView +import com.google.android.fhir.datacapture.extensions.itemMedia import com.google.android.fhir.datacapture.views.QuestionnaireViewItem +import com.google.android.fhir.datacapture.views.compose.Header +import com.google.android.fhir.datacapture.views.compose.MediaItem -internal object DisplayViewHolderFactory : - QuestionnaireItemAndroidViewHolderFactory(R.layout.display_view) { +internal object DisplayViewHolderFactory : QuestionnaireItemComposeViewHolderFactory { override fun getQuestionnaireItemViewHolderDelegate() = - object : QuestionnaireItemAndroidViewHolderDelegate { - private lateinit var header: HeaderView - override lateinit var questionnaireViewItem: QuestionnaireViewItem + object : QuestionnaireItemComposeViewHolderDelegate { - override fun init(itemView: View) { - header = itemView.findViewById(R.id.header) - } - - override fun bind(questionnaireViewItem: QuestionnaireViewItem) { - header.bind(questionnaireViewItem) - } - - override fun setReadOnly(isReadOnly: Boolean) { - // Display type questions have no user input + @Composable + override fun Content(questionnaireViewItem: QuestionnaireViewItem) { + Column( + modifier = + Modifier.fillMaxWidth() + .padding( + horizontal = dimensionResource(R.dimen.item_margin_horizontal), + vertical = dimensionResource(R.dimen.item_margin_vertical), + ), + ) { + Header(questionnaireViewItem) + questionnaireViewItem.questionnaireItem.itemMedia?.let { MediaItem(it) } + } } } } diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DropDownViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DropDownViewHolderFactory.kt index 444806b0f0..8668cea420 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DropDownViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DropDownViewHolderFactory.kt @@ -38,6 +38,7 @@ import com.google.android.fhir.datacapture.extensions.getValidationErrorMessage import com.google.android.fhir.datacapture.extensions.identifierString import com.google.android.fhir.datacapture.extensions.itemAnswerOptionImage import com.google.android.fhir.datacapture.extensions.localizedFlyoverSpanned +import com.google.android.fhir.datacapture.extensions.toAnnotatedString import com.google.android.fhir.datacapture.extensions.toSpanned import com.google.android.fhir.datacapture.extensions.tryUnwrapContext import com.google.android.fhir.datacapture.validation.ValidationResult @@ -213,4 +214,6 @@ internal data class DropDownAnswerOption( } fun answerOptionStringSpanned(): Spanned = answerOptionString.toSpanned() + + fun answerOptionAnnotatedString() = answerOptionString.toAnnotatedString() } diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/QuantityViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/QuantityViewHolderFactory.kt index 5f0a29dd4e..ed1bdc892a 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/QuantityViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/QuantityViewHolderFactory.kt @@ -16,216 +16,192 @@ package com.google.android.fhir.datacapture.views.factories -import android.content.Context -import android.text.Editable -import android.text.InputType -import android.text.TextWatcher -import android.view.View -import android.view.inputmethod.EditorInfo -import android.view.inputmethod.InputMethodManager -import android.widget.AdapterView -import androidx.appcompat.app.AppCompatActivity -import androidx.core.widget.doAfterTextChanged -import androidx.lifecycle.lifecycleScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.extensions.getRequiredOrOptionalText -import com.google.android.fhir.datacapture.extensions.localizedFlyoverSpanned +import com.google.android.fhir.datacapture.extensions.itemMedia +import com.google.android.fhir.datacapture.extensions.localizedFlyoverAnnotatedString import com.google.android.fhir.datacapture.extensions.toCoding -import com.google.android.fhir.datacapture.extensions.tryUnwrapContext import com.google.android.fhir.datacapture.extensions.unitOption import com.google.android.fhir.datacapture.validation.Invalid import com.google.android.fhir.datacapture.validation.NotValidated import com.google.android.fhir.datacapture.validation.Valid import com.google.android.fhir.datacapture.validation.ValidationResult -import com.google.android.fhir.datacapture.views.HeaderView import com.google.android.fhir.datacapture.views.QuestionnaireViewItem -import com.google.android.material.textfield.MaterialAutoCompleteTextView -import com.google.android.material.textfield.TextInputEditText -import com.google.android.material.textfield.TextInputLayout +import com.google.android.fhir.datacapture.views.compose.EditTextFieldItem +import com.google.android.fhir.datacapture.views.compose.EditTextFieldState +import com.google.android.fhir.datacapture.views.compose.ExposedDropDownMenuBoxItem +import com.google.android.fhir.datacapture.views.compose.Header +import com.google.android.fhir.datacapture.views.compose.MediaItem import java.math.BigDecimal +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.Quantity import org.hl7.fhir.r4.model.QuestionnaireResponse -internal object QuantityViewHolderFactory : - QuestionnaireItemAndroidViewHolderFactory(R.layout.quantity_view) { +internal object QuantityViewHolderFactory : QuestionnaireItemComposeViewHolderFactory { override fun getQuestionnaireItemViewHolderDelegate() = - object : QuestionnaireItemAndroidViewHolderDelegate { - override lateinit var questionnaireViewItem: QuestionnaireViewItem - - private lateinit var header: HeaderView - protected lateinit var textInputLayout: TextInputLayout - private lateinit var textInputEditText: TextInputEditText - private lateinit var unitTextInputLayout: TextInputLayout - private lateinit var unitAutoCompleteTextView: MaterialAutoCompleteTextView - private var textWatcher: TextWatcher? = null - private lateinit var appContext: AppCompatActivity - - override fun init(itemView: View) { - appContext = itemView.context.tryUnwrapContext()!! - header = itemView.findViewById(R.id.header) - textInputLayout = itemView.findViewById(R.id.text_input_layout) - textInputEditText = - itemView.findViewById(R.id.text_input_edit_text).apply { - setRawInputType(QUANTITY_INPUT_TYPE) - // Override `setOnEditorActionListener` to avoid crash with `IllegalStateException` if - // it's not possible to move focus forward. - // See - // https://stackoverflow.com/questions/13614101/fatal-crash-focus-search-returned-a-view-that-wasnt-able-to-take-focus/47991577 - setOnEditorActionListener { view, actionId, _ -> - if (actionId != EditorInfo.IME_ACTION_NEXT) { - false - } - view.focusSearch(View.FOCUS_DOWN)?.requestFocus(View.FOCUS_DOWN) ?: false - } - setOnFocusChangeListener { view, focused -> - if (!focused) { - (view.context.applicationContext.getSystemService(Context.INPUT_METHOD_SERVICE) - as InputMethodManager) - .hideSoftInputFromWindow(view.windowToken, 0) - - appContext.lifecycleScope.launch { - // Update answer even if the text box loses focus without any change. This will - // mark - // the - // questionnaire response item as being modified in the view model and trigger - // validation. - handleInput(textInputEditText.editableText, null) - } - } - } + object : QuestionnaireItemComposeViewHolderDelegate { + + @Composable + override fun Content(questionnaireViewItem: QuestionnaireViewItem) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope { Dispatchers.Main } + val text = remember(questionnaireViewItem) { uiInputText(questionnaireViewItem) } + val isReadOnly = + remember(questionnaireViewItem) { questionnaireViewItem.questionnaireItem.readOnly } + val unitOptions = + remember(questionnaireViewItem) { unitDropDownOptions(questionnaireViewItem) } + val dropDownOptions = + remember(unitOptions) { unitOptions.mapNotNull { it.toDropDownAnswerOption() } } + val selectedOption = + remember(questionnaireViewItem) { + unitTextCoding(questionnaireViewItem)?.toDropDownAnswerOption() } - unitTextInputLayout = itemView.findViewById(R.id.unit_text_input_layout) - unitAutoCompleteTextView = - itemView.findViewById(R.id.unit_auto_complete).apply { - onItemClickListener = - AdapterView.OnItemClickListener { _, _, position, _ -> - appContext.lifecycleScope.launch { - handleInput( - null, - questionnaireViewItem.questionnaireItem.unitOption[position], - ) - } - } + var quantity by + remember(questionnaireViewItem) { + mutableStateOf(UiQuantity(text, selectedOption?.findCoding(unitOptions))) } - } - override fun bind(questionnaireViewItem: QuestionnaireViewItem) { - header.bind(questionnaireViewItem) - with(textInputLayout) { - hint = questionnaireViewItem.enabledDisplayItems.localizedFlyoverSpanned - helperText = getRequiredOrOptionalText(questionnaireViewItem, context) - } - displayValidationResult(questionnaireViewItem.validationResult) + val validationUiMessage = uiValidationMessage(questionnaireViewItem.validationResult) - textInputEditText.removeTextChangedListener(textWatcher) - updateUI() + LaunchedEffect(quantity) { + coroutineScope.launch { handleInput(questionnaireViewItem, quantity) } + } - textWatcher = - textInputEditText.doAfterTextChanged { editable: Editable? -> - appContext.lifecycleScope.launch { handleInput(editable!!, null) } + val composeViewQuestionnaireState = + remember(questionnaireViewItem) { + EditTextFieldState( + initialInputText = text, + handleTextInputChange = { quantity = UiQuantity(it, quantity.unitDropDown) }, + coroutineScope = coroutineScope, + hint = questionnaireViewItem.enabledDisplayItems.localizedFlyoverAnnotatedString, + helperText = validationUiMessage.takeIf { !it.isNullOrBlank() } + ?: getRequiredOrOptionalText(questionnaireViewItem, context), + isError = !validationUiMessage.isNullOrBlank(), + isReadOnly = isReadOnly, + keyboardOptions = + KeyboardOptions(keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Done), + isMultiLine = false, + ) } - } - private fun displayValidationResult(validationResult: ValidationResult) { - textInputLayout.error = - when (validationResult) { - is NotValidated, - Valid, -> null - is Invalid -> validationResult.getSingleStringValidationMessage() + Column( + modifier = + Modifier.fillMaxWidth() + .padding( + horizontal = dimensionResource(R.dimen.item_margin_horizontal), + vertical = dimensionResource(R.dimen.item_margin_vertical), + ), + ) { + Header(questionnaireViewItem) + questionnaireViewItem.questionnaireItem.itemMedia?.let { MediaItem(it) } + + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + EditTextFieldItem( + modifier = Modifier.weight(1f), + textFieldState = composeViewQuestionnaireState, + ) + Spacer(modifier = Modifier.width(dimensionResource(R.dimen.item_margin_horizontal))) + ExposedDropDownMenuBoxItem( + modifier = Modifier.weight(1f), + enabled = !isReadOnly, + selectedOption = selectedOption, + options = dropDownOptions, + ) { answerOption -> + quantity = UiQuantity(quantity.value, answerOption?.findCoding(unitOptions)) + } } + } } - override fun setReadOnly(isReadOnly: Boolean) { - textInputLayout.isEnabled = !isReadOnly - textInputEditText.isEnabled = !isReadOnly - unitTextInputLayout.isEnabled = !isReadOnly - unitAutoCompleteTextView.isEnabled = !isReadOnly - } + private fun uiValidationMessage(validationResult: ValidationResult): String? = + when (validationResult) { + is NotValidated, + Valid, -> null + is Invalid -> validationResult.getSingleStringValidationMessage() + } - private suspend fun handleInput(editable: Editable?, unitDropDown: Coding?) { - var decimal: BigDecimal? = null - var unit: Coding? = null + private suspend fun handleInput( + questionnaireViewItem: QuestionnaireViewItem, + input: UiQuantity, + ) { + val currentAnswerQuantity = questionnaireViewItem.answers.singleOrNull()?.valueQuantity + val draftAnswer = questionnaireViewItem.draftAnswer - // Read decimal value and unit from complete answer - questionnaireViewItem.answers.singleOrNull()?.let { - val quantity = it.value as Quantity - decimal = quantity.value - unit = Coding(quantity.system, quantity.code, quantity.unit) - } + val decimal = + input.value?.toBigDecimalOrNull() + ?: (draftAnswer as? BigDecimal) ?: currentAnswerQuantity?.value + val unit = + input.unitDropDown ?: ((draftAnswer as? Coding) ?: currentAnswerQuantity?.toCoding()) - // Read decimal value and unit from partial answer - questionnaireViewItem.draftAnswer?.let { - when (it) { - is BigDecimal -> decimal = it - is Coding -> unit = it + when { + decimal == null && unit == null -> { + questionnaireViewItem.clearAnswer() + } + decimal == null -> { + questionnaireViewItem.setDraftAnswer(unit) + } + unit == null -> { + questionnaireViewItem.setDraftAnswer(decimal) + } + else -> { + questionnaireViewItem.setAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = Quantity(null, decimal.toDouble(), unit.system, unit.code, unit.display) + }, + ) } - } - - // Update decimal value and unit - editable?.toString()?.let { decimal = it.toBigDecimalOrNull() } - unitDropDown?.let { unit = it } - - if (decimal == null && unit == null) { - questionnaireViewItem.clearAnswer() - } else if (decimal == null) { - questionnaireViewItem.setDraftAnswer(unit) - } else if (unit == null) { - questionnaireViewItem.setDraftAnswer(decimal) - } else { - questionnaireViewItem.setAnswer( - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { - value = - Quantity(null, decimal!!.toDouble(), unit!!.system, unit!!.code, unit!!.display) - }, - ) } } - private fun updateUI() { - val text = - questionnaireViewItem.answers.singleOrNull()?.valueQuantity?.value?.toString() - ?: questionnaireViewItem.draftAnswer?.let { - if (it is BigDecimal) it.toString() else "" - } - ?: "" - if (isTextUpdatesRequired(text, textInputEditText.text.toString())) { - textInputEditText.setText(text) - } - - val unit = - questionnaireViewItem.answers.singleOrNull()?.valueQuantity?.toCoding() - ?: questionnaireViewItem.draftAnswer?.let { if (it is Coding) it else null } - ?: questionnaireViewItem.questionnaireItem.initial - ?.firstOrNull() - ?.valueQuantity - ?.toCoding() - unitAutoCompleteTextView.setText(unit?.display ?: "") - - val unitAdapter = - AnswerOptionDropDownArrayAdapter( - appContext, - R.layout.drop_down_list_item, - questionnaireViewItem.questionnaireItem.unitOption.map { - DropDownAnswerOption(it.code, it.display) - }, - ) - unitAutoCompleteTextView.setAdapter(unitAdapter) + private fun uiInputText(questionnaireViewItem: QuestionnaireViewItem): String { + return questionnaireViewItem.answers.singleOrNull()?.valueQuantity?.value?.toString() + ?: questionnaireViewItem.draftAnswer?.let { if (it is BigDecimal) it.toString() else "" } + ?: "" } - private fun isTextUpdatesRequired(answerText: String, inputText: String): Boolean { - if (answerText.isEmpty() && inputText.isEmpty()) { - return false - } - if (answerText.isEmpty() || inputText.isEmpty()) { - return true - } - // Avoid shifting focus by updating text field if the values are the same - return answerText.toDouble() != inputText.toDouble() - } + private fun unitTextCoding(questionnaireViewItem: QuestionnaireViewItem) = + questionnaireViewItem.answers.singleOrNull()?.valueQuantity?.toCoding() + ?: questionnaireViewItem.draftAnswer?.let { it as? Coding } + ?: questionnaireViewItem.questionnaireItem.initial + ?.firstOrNull() + ?.valueQuantity + ?.toCoding() + + private fun unitDropDownOptions(questionnaireViewItem: QuestionnaireViewItem): List = + questionnaireViewItem.questionnaireItem.unitOption + + private fun Coding.toDropDownAnswerOption() = + takeIf { it.hasCode() || it.hasDisplay() } + ?.let { + DropDownAnswerOption(answerId = it.code ?: it.display, answerOptionString = it.display) + } + + private fun DropDownAnswerOption.findCoding(options: List) = + options.find { answerId == it.code } ?: options.find { answerId == it.display } } } -const val QUANTITY_INPUT_TYPE = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL +private data class UiQuantity(val value: String?, val unitDropDown: Coding?) diff --git a/datacapture/src/main/res/layout/display_view.xml b/datacapture/src/main/res/layout/display_view.xml deleted file mode 100644 index 6529da065b..0000000000 --- a/datacapture/src/main/res/layout/display_view.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - diff --git a/datacapture/src/main/res/layout/quantity_view.xml b/datacapture/src/main/res/layout/quantity_view.xml deleted file mode 100644 index f27ea4f251..0000000000 --- a/datacapture/src/main/res/layout/quantity_view.xml +++ /dev/null @@ -1,84 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - -