diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/QuestionnaireUiEspressoTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/QuestionnaireUiEspressoTest.kt index eda368a443..7e12a74471 100644 --- a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/QuestionnaireUiEspressoTest.kt +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/QuestionnaireUiEspressoTest.kt @@ -19,13 +19,26 @@ package com.google.android.fhir.datacapture.test import android.view.View import android.widget.FrameLayout import android.widget.TextView +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.filterToOne +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onChildren import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTextReplacement import androidx.fragment.app.commitNow import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.ViewHolder @@ -36,31 +49,29 @@ import androidx.test.espresso.action.ViewActions import androidx.test.espresso.assertion.ViewAssertions import androidx.test.espresso.assertion.ViewAssertions.doesNotExist import androidx.test.espresso.contrib.RecyclerViewActions -import androidx.test.espresso.matcher.RootMatchers import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.filters.SdkSuppress import androidx.test.platform.app.InstrumentationRegistry import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.context.FhirVersionEnum import ca.uhn.fhir.parser.IParser import com.google.android.fhir.datacapture.QuestionnaireFragment -import com.google.android.fhir.datacapture.test.utilities.clickIcon +import com.google.android.fhir.datacapture.R +import com.google.android.fhir.datacapture.extensions.localDate +import com.google.android.fhir.datacapture.extensions.localDateTime import com.google.android.fhir.datacapture.test.utilities.clickOnText import com.google.android.fhir.datacapture.validation.Invalid import com.google.android.fhir.datacapture.validation.QuestionnaireResponseValidator import com.google.android.fhir.datacapture.validation.Valid +import com.google.android.fhir.datacapture.views.compose.DATE_TEXT_INPUT_FIELD import com.google.android.fhir.datacapture.views.compose.EDIT_TEXT_FIELD_TEST_TAG import com.google.android.fhir.datacapture.views.compose.HANDLE_INPUT_DEBOUNCE_TIME -import com.google.android.fhir.datacapture.views.factories.localDate -import com.google.android.fhir.datacapture.views.factories.localDateTime +import com.google.android.fhir.datacapture.views.compose.TIME_PICKER_INPUT_FIELD import com.google.android.material.progressindicator.LinearProgressIndicator -import com.google.android.material.textfield.TextInputEditText -import com.google.android.material.textfield.TextInputLayout import com.google.common.truth.Truth.assertThat import java.math.BigDecimal import java.time.LocalDate @@ -74,7 +85,6 @@ import org.hl7.fhir.r4.model.DateTimeType import org.hl7.fhir.r4.model.DateType import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse -import org.junit.Assert import org.junit.Before import org.junit.Rule import org.junit.Test @@ -227,57 +237,70 @@ class QuestionnaireUiEspressoTest { buildFragmentFromQuestionnaire("/component_date_time_picker.json") // Add month and day. No need to add slashes as they are added automatically - onView(withId(R.id.date_input_edit_text)) - .perform(ViewActions.click()) - .perform(ViewActions.typeTextIntoFocusedView("0105")) - - onView(withId(R.id.date_input_layout)).check { view, _ -> - val actualError = (view as TextInputLayout).error - assertThat(actualError).isEqualTo("Date format needs to be mm/dd/yyyy (e.g. 01/31/2023)") - } - onView(withId(R.id.time_input_layout)).check { view, _ -> assertThat(view.isEnabled).isFalse() } + composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextReplacement("0105") + + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.Error, + "Date format needs to be mm/dd/yyyy (e.g. 01/31/2023)", + ), + ) + composeTestRule.onNodeWithTag(TIME_PICKER_INPUT_FIELD).assertIsNotEnabled() } @Test fun dateTimePicker_shouldEnableTimePickerWithCorrectDate_butNotSaveInQuestionnaireResponse() { buildFragmentFromQuestionnaire("/component_date_time_picker.json") - onView(withId(R.id.date_input_edit_text)) - .perform(ViewActions.click()) - .perform(ViewActions.typeTextIntoFocusedView("01052005")) + composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextReplacement("01052005") - onView(withId(R.id.date_input_layout)).check { view, _ -> - val actualError = (view as TextInputLayout).error - assertThat(actualError).isEqualTo(null) - } - - onView(withId(R.id.time_input_layout)).check { view, _ -> assertThat(view.isEnabled).isTrue() } + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .assert( + SemanticsMatcher.keyNotDefined( + SemanticsProperties.Error, + ), + ) + composeTestRule.onNodeWithTag(TIME_PICKER_INPUT_FIELD).assertIsEnabled() - runBlocking { - assertThat(getQuestionnaireResponse().item.size).isEqualTo(1) - assertThat(getQuestionnaireResponse().item.first().answer.size).isEqualTo(0) - } + val questionnaireResponse = runBlocking { getQuestionnaireResponse() } + assertThat(questionnaireResponse.item.size).isEqualTo(1) + assertThat(questionnaireResponse.item.first().answer.size).isEqualTo(1) + val answer = questionnaireResponse.item.first().answer.first().valueDateTimeType + assertThat(answer.localDateTime).isEqualTo(LocalDateTime.of(2005, 1, 5, 0, 0)) } @Test fun dateTimePicker_shouldSetAnswerWhenDateAndTimeAreFilled() { buildFragmentFromQuestionnaire("/component_date_time_picker.json") - onView(withId(R.id.date_input_edit_text)) - .perform(ViewActions.click()) - .perform(ViewActions.typeTextIntoFocusedView("01052005")) + composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextReplacement("01052005") - onView(withId(R.id.time_input_layout)).perform(clickIcon(true)) - clickOnText("AM") - clickOnText("6") - clickOnText("10") - clickOnText("OK") + composeTestRule + .onNodeWithTag(TIME_PICKER_INPUT_FIELD) + .onChildren() + .filterToOne( + SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button), + ) + .performClick() - runBlocking { - val answer = getQuestionnaireResponse().item.first().answer.first().valueDateTimeType - // check Locale - assertThat(answer.localDateTime).isEqualTo(LocalDateTime.of(2005, 1, 5, 6, 10)) - } + composeTestRule.onNodeWithText("AM").performClick() + composeTestRule.onNodeWithContentDescription("Select hour", substring = true).performClick() + composeTestRule.onNodeWithContentDescription("6 o'clock", substring = true).performClick() + + composeTestRule.onNodeWithContentDescription("Select minutes", substring = true).performClick() + composeTestRule.onNodeWithContentDescription("10 minutes", substring = true).performClick() + + composeTestRule.onNodeWithText("OK").performClick() + // Synchronize + composeTestRule.waitForIdle() + + val questionnaireResponse = runBlocking { getQuestionnaireResponse() } + val answer = questionnaireResponse.item.first().answer.first().valueDateTimeType + // check Locale + assertThat(answer.localDateTime).isEqualTo(LocalDateTime.of(2005, 1, 5, 6, 10)) } @Test @@ -285,28 +308,25 @@ class QuestionnaireUiEspressoTest { buildFragmentFromQuestionnaire("/component_date_picker.json") // Add month and day. No need to add slashes as they are added automatically - onView(withId(R.id.text_input_edit_text)) - .perform(ViewActions.click()) - .perform(ViewActions.typeTextIntoFocusedView("0105")) - - onView(withId(R.id.text_input_layout)).check { view, _ -> - val actualError = (view as TextInputLayout).error - assertThat(actualError).isEqualTo("Date format needs to be mm/dd/yyyy (e.g. 01/31/2023)") - } + composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextInput("0105") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.Error, + "Date format needs to be mm/dd/yyyy (e.g. 01/31/2023)", + ), + ) } @Test fun datePicker_shouldSaveInQuestionnaireResponseWhenCorrectDateEntered() { buildFragmentFromQuestionnaire("/component_date_picker.json") - onView(withId(R.id.text_input_edit_text)) - .perform(ViewActions.click()) - .perform(ViewActions.typeTextIntoFocusedView("01052005")) - - onView(withId(R.id.text_input_layout)).check { view, _ -> - val actualError = (view as TextInputLayout).error - assertThat(actualError).isEqualTo(null) - } + composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextInput("01052005") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .assert(SemanticsMatcher.keyNotDefined(SemanticsProperties.Error)) runBlocking { val answer = getQuestionnaireResponse().item.first().answer.first().valueDateType @@ -338,11 +358,14 @@ class QuestionnaireUiEspressoTest { } buildFragmentFromQuestionnaire(questionnaire) - onView(withId(R.id.text_input_layout)).perform(clickIcon(true)) - onView(CoreMatchers.allOf(ViewMatchers.withText("OK"))) - .inRoot(RootMatchers.isDialog()) - .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) - .perform(ViewActions.click()) + composeTestRule + .onNodeWithContentDescription(context.getString(R.string.select_date)) + .performClick() + composeTestRule + .onNode(hasText("OK") and hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performClick() + composeTestRule.waitForIdle() // Synchronize val today = DateTimeType.today().valueAsString @@ -385,11 +408,14 @@ class QuestionnaireUiEspressoTest { } buildFragmentFromQuestionnaire(questionnaire) - onView(withId(R.id.text_input_layout)).perform(clickIcon(true)) - onView(CoreMatchers.allOf(ViewMatchers.withText("OK"))) - .inRoot(RootMatchers.isDialog()) - .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) - .perform(ViewActions.click()) + composeTestRule + .onNodeWithContentDescription(context.getString(R.string.select_date)) + .performClick() + composeTestRule + .onNode(hasText("OK") and hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performClick() + composeTestRule.waitForIdle() // Synchronize val maxDateAllowed = maxDate.valueAsString @@ -432,11 +458,14 @@ class QuestionnaireUiEspressoTest { } buildFragmentFromQuestionnaire(questionnaire) - onView(withId(R.id.text_input_layout)).perform(clickIcon(true)) - onView(CoreMatchers.allOf(ViewMatchers.withText("OK"))) - .inRoot(RootMatchers.isDialog()) - .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) - .perform(ViewActions.click()) + composeTestRule + .onNodeWithContentDescription(context.getString(R.string.select_date)) + .performClick() + composeTestRule + .onNode(hasText("OK") and hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performClick() + composeTestRule.waitForIdle() // Synchronize val minDateAllowed = minDate.valueAsString @@ -456,7 +485,7 @@ class QuestionnaireUiEspressoTest { } @Test - fun datePicker_shouldThrowException_whenMinValueRangeIsGreaterThanMaxValueRange() { + fun datePicker_shouldProhibitInputWithErrorMessage_whenMinValueRangeIsGreaterThanMaxValueRange() { val questionnaire = Questionnaire().apply { id = "a-questionnaire" @@ -480,16 +509,18 @@ class QuestionnaireUiEspressoTest { } buildFragmentFromQuestionnaire(questionnaire) - val exception = - Assert.assertThrows(IllegalArgumentException::class.java) { - onView(withId(com.google.android.fhir.datacapture.R.id.text_input_layout)) - .perform(clickIcon(true)) - onView(CoreMatchers.allOf(ViewMatchers.withText("OK"))) - .inRoot(RootMatchers.isDialog()) - .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) - .perform(ViewActions.click()) - } - assertThat(exception.message).isEqualTo("minValue cannot be greater than maxValue") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.Error, + "minValue cannot be greater than maxValue", + ), + ) + composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).assertIsNotEnabled() + composeTestRule + .onNodeWithContentDescription(context.getString(R.string.select_date)) + .assertIsNotEnabled() } @Test @@ -551,19 +582,21 @@ class QuestionnaireUiEspressoTest { } @Test - @SdkSuppress(minSdkVersion = 33) fun clearAllAnswers_shouldClearDraftAnswer() { val questionnaireFragment = buildFragmentFromQuestionnaire("/component_date_picker.json") // Add month and day. No need to add slashes as they are added automatically - onView(withId(R.id.text_input_edit_text)) - .perform(ViewActions.click()) - .perform(ViewActions.typeTextIntoFocusedView("0105")) + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) + .performTextInput("0105") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals("01/05/") questionnaireFragment.clearAllAnswers() - onView(withId(R.id.text_input_edit_text)).check { view, _ -> - assertThat((view as TextInputEditText).text.toString()).isEmpty() - } + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals("") } @Test @@ -801,7 +834,7 @@ class QuestionnaireUiEspressoTest { activityScenarioRule.scenario.onActivity { activity -> activity.supportFragmentManager.commitNow { setReorderingAllowed(true) - add(R.id.container_holder, fragment) + add(com.google.android.fhir.datacapture.test.R.id.container_holder, fragment) } } } @@ -819,7 +852,7 @@ class QuestionnaireUiEspressoTest { activityScenarioRule.scenario.onActivity { activity -> activity.supportFragmentManager.commitNow { setReorderingAllowed(true) - add(R.id.container_holder, questionnaireFragment) + add(com.google.android.fhir.datacapture.test.R.id.container_holder, questionnaireFragment) } } } @@ -831,8 +864,9 @@ class QuestionnaireUiEspressoTest { var testQuestionnaireFragment: QuestionnaireFragment? = null activityScenarioRule.scenario.onActivity { activity -> testQuestionnaireFragment = - activity.supportFragmentManager.findFragmentById(R.id.container_holder) - as QuestionnaireFragment + activity.supportFragmentManager.findFragmentById( + com.google.android.fhir.datacapture.test.R.id.container_holder, + ) as QuestionnaireFragment } return testQuestionnaireFragment!!.getQuestionnaireResponse() } diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/factories/AutoCompleteViewHolderFactoryEspressoTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/AutoCompleteViewHolderFactoryEspressoTest.kt similarity index 60% rename from datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/factories/AutoCompleteViewHolderFactoryEspressoTest.kt rename to datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/AutoCompleteViewHolderFactoryEspressoTest.kt index 87f38fd25b..f9c7f3f637 100644 --- a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/factories/AutoCompleteViewHolderFactoryEspressoTest.kt +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/AutoCompleteViewHolderFactoryEspressoTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,26 +14,30 @@ * limitations under the License. */ -package com.google.android.fhir.datacapture.views.factories +package com.google.android.fhir.datacapture.test.views -import android.view.View -import android.widget.AutoCompleteTextView import android.widget.FrameLayout -import android.widget.TextView -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions -import androidx.test.espresso.assertion.ViewAssertions -import androidx.test.espresso.matcher.RootMatchers -import androidx.test.espresso.matcher.ViewMatchers +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasTextExactly +import androidx.compose.ui.test.isPopup +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextReplacement import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.platform.app.InstrumentationRegistry -import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.test.TestActivity -import com.google.android.fhir.datacapture.test.utilities.delayMainThread import com.google.android.fhir.datacapture.validation.NotValidated import com.google.android.fhir.datacapture.views.QuestionnaireViewItem -import com.google.android.material.chip.ChipGroup -import com.google.android.material.textfield.MaterialAutoCompleteTextView +import com.google.android.fhir.datacapture.views.compose.DROP_DOWN_ANSWER_MENU_ITEM_TAG +import com.google.android.fhir.datacapture.views.compose.MULTI_AUTO_COMPLETE_INPUT_CHIP_TAG +import com.google.android.fhir.datacapture.views.compose.MULTI_AUTO_COMPLETE_TEXT_FIELD_TAG +import com.google.android.fhir.datacapture.views.factories.AutoCompleteViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder import com.google.common.truth.Truth.assertThat import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.Questionnaire @@ -43,19 +47,22 @@ import org.junit.Rule import org.junit.Test class AutoCompleteViewHolderFactoryEspressoTest { - @Rule - @JvmField + @get:Rule var activityScenarioRule: ActivityScenarioRule = ActivityScenarioRule(TestActivity::class.java) - private lateinit var parent: FrameLayout + @get:Rule val composeTestRule = createEmptyComposeRule() + private lateinit var viewHolder: QuestionnaireItemViewHolder @Before fun setup() { - activityScenarioRule.scenario.onActivity { activity -> parent = FrameLayout(activity) } - viewHolder = AutoCompleteViewHolderFactory.create(parent) - setTestLayout(viewHolder.itemView) + activityScenarioRule.scenario.onActivity { activity -> + viewHolder = AutoCompleteViewHolderFactory.create(FrameLayout(activity)) + activity.setContentView(viewHolder.itemView) + } + + InstrumentationRegistry.getInstrumentation().waitForIdleSync() } @Test @@ -67,16 +74,11 @@ class AutoCompleteViewHolderFactoryEspressoTest { validationResult = NotValidated, answersChangedCallback = { _, _, _, _ -> }, ) - runOnUI { viewHolder.bind(questionnaireViewItem) } - - onView(ViewMatchers.withId(R.id.autoCompleteTextView)).perform(ViewActions.typeText("Coding 1")) - assertThat( - viewHolder.itemView - .findViewById(R.id.autoCompleteTextView) - .adapter - .count, - ) - .isEqualTo(1) + viewHolder.bind(questionnaireViewItem) + composeTestRule + .onNodeWithTag(MULTI_AUTO_COMPLETE_TEXT_FIELD_TAG) + .performTextReplacement("Coding 1") + composeTestRule.onAllNodes(hasTestTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG)).assertCountEquals(1) } @Test @@ -89,23 +91,24 @@ class AutoCompleteViewHolderFactoryEspressoTest { validationResult = NotValidated, answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, ) - runOnUI { viewHolder.bind(questionnaireViewItem) } + viewHolder.bind(questionnaireViewItem) - onView(ViewMatchers.withId(R.id.autoCompleteTextView)).perform(ViewActions.typeText("Coding 3")) - runOnUI { - viewHolder.itemView - .findViewById(R.id.autoCompleteTextView) - .showDropDown() - } - onView(ViewMatchers.withId(R.id.autoCompleteTextView)).perform(delayMainThread()) - onView(ViewMatchers.withText("Coding 3")) - .inRoot(RootMatchers.isPlatformPopup()) - .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) - .perform(ViewActions.click()) - assertThat( - viewHolder.itemView.findViewById(R.id.autoCompleteTextView).text.toString(), + composeTestRule + .onNodeWithTag(MULTI_AUTO_COMPLETE_TEXT_FIELD_TAG) + .performTextReplacement("Coding 3") + + composeTestRule + .onNode( + hasTestTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG) and + hasTextExactly("Coding 3") and + hasAnyAncestor(isPopup()), ) - .isEmpty() + .assertIsDisplayed() + .performClick() + + composeTestRule.onNodeWithTag(MULTI_AUTO_COMPLETE_TEXT_FIELD_TAG).assertTextEquals("") + + composeTestRule.waitUntil { answerHolder != null } assertThat(answerHolder!!.map { it.valueCoding.display }) .containsExactly("Coding 1", "Coding 5", "Coding 3") } @@ -119,21 +122,8 @@ class AutoCompleteViewHolderFactoryEspressoTest { validationResult = NotValidated, answersChangedCallback = { _, _, _, _ -> }, ) - runOnUI { viewHolder.bind(questionnaireViewItem) } - - assertThat(viewHolder.itemView.findViewById(R.id.chipContainer).childCount) - .isEqualTo(2) - } - - /** Method to run code snippet on UI/main thread */ - private fun runOnUI(action: () -> Unit) { - activityScenarioRule.scenario.onActivity { action() } - } - - /** Method to set content view for test activity */ - private fun setTestLayout(view: View) { - activityScenarioRule.scenario.onActivity { activity -> activity.setContentView(view) } - InstrumentationRegistry.getInstrumentation().waitForIdleSync() + viewHolder.bind(questionnaireViewItem) + composeTestRule.onAllNodes(hasTestTag(MULTI_AUTO_COMPLETE_INPUT_CHIP_TAG)).assertCountEquals(2) } private fun answerOptions(repeats: Boolean, vararg options: String) = diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/AutoCompleteViewHolderFactoryTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/AutoCompleteViewHolderFactoryTest.kt similarity index 73% rename from datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/AutoCompleteViewHolderFactoryTest.kt rename to datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/AutoCompleteViewHolderFactoryTest.kt index 04d63a634b..a9aca1ddcb 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/AutoCompleteViewHolderFactoryTest.kt +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/AutoCompleteViewHolderFactoryTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 Google LLC + * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,42 +14,68 @@ * limitations under the License. */ -package com.google.android.fhir.datacapture.views.factories +package com.google.android.fhir.datacapture.test.views -import android.view.View import android.widget.FrameLayout import android.widget.TextView -import androidx.appcompat.app.AppCompatActivity +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.onNodeWithTag import androidx.core.view.get +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.extensions.displayString import com.google.android.fhir.datacapture.extensions.identifierString +import com.google.android.fhir.datacapture.test.TestActivity import com.google.android.fhir.datacapture.validation.Invalid import com.google.android.fhir.datacapture.validation.NotValidated import com.google.android.fhir.datacapture.validation.Valid import com.google.android.fhir.datacapture.views.QuestionTextConfiguration import com.google.android.fhir.datacapture.views.QuestionnaireViewItem -import com.google.android.material.chip.Chip -import com.google.android.material.chip.ChipGroup -import com.google.android.material.textfield.TextInputLayout +import com.google.android.fhir.datacapture.views.compose.ERROR_TEXT_AT_HEADER_TEST_TAG +import com.google.android.fhir.datacapture.views.compose.MULTI_AUTO_COMPLETE_INPUT_CHIP_TAG +import com.google.android.fhir.datacapture.views.compose.MULTI_AUTO_COMPLETE_TEXT_FIELD_TAG +import com.google.android.fhir.datacapture.views.compose.REQUIRED_OPTIONAL_HEADER_TEXT_TAG +import com.google.android.fhir.datacapture.views.factories.AutoCompleteViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder import com.google.common.truth.Truth.assertThat import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.junit.Before +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.robolectric.Robolectric -import org.robolectric.RobolectricTestRunner -@RunWith(RobolectricTestRunner::class) +@RunWith(AndroidJUnit4::class) class AutoCompleteViewHolderFactoryTest { - private val parent = - FrameLayout( - Robolectric.buildActivity(AppCompatActivity::class.java).create().get().apply { - setTheme(com.google.android.material.R.style.Theme_Material3_DayNight) - }, - ) - private val viewHolder = AutoCompleteViewHolderFactory.create(parent) + @get:Rule + val activityScenarioRule: ActivityScenarioRule = + ActivityScenarioRule(TestActivity::class.java) + + @get:Rule val composeTestRule = createEmptyComposeRule() + + private lateinit var viewHolder: QuestionnaireItemViewHolder + + @Before + fun setUp() { + activityScenarioRule.scenario.onActivity { activity -> + viewHolder = AutoCompleteViewHolderFactory.create(FrameLayout(activity)) + activity.setContentView(viewHolder.itemView) + } + + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + } @Test fun shouldSetQuestionHeader() { @@ -62,6 +88,9 @@ class AutoCompleteViewHolderFactoryTest { ), ) + // Synchronize + composeTestRule.waitForIdle() + assertThat(viewHolder.itemView.findViewById(R.id.question).text.toString()) .isEqualTo("Question") } @@ -80,6 +109,7 @@ class AutoCompleteViewHolderFactoryTest { .setValue(Coding().setCode("test2-code").setDisplay("Test2 Code")), ) } + viewHolder.bind( QuestionnaireViewItem( questionnaireItem, @@ -88,7 +118,7 @@ class AutoCompleteViewHolderFactoryTest { QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = questionnaireItem.answerOption - .first { it.value.displayString(parent.context) == "Test1 Code" } + .first { it.value.displayString(viewHolder.itemView.context) == "Test1 Code" } .valueCoding }, ) @@ -98,8 +128,7 @@ class AutoCompleteViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.chipContainer).childCount) - .isEqualTo(1) + composeTestRule.onAllNodes(hasTestTag(MULTI_AUTO_COMPLETE_INPUT_CHIP_TAG)).assertCountEquals(1) } @Test @@ -132,14 +161,18 @@ class AutoCompleteViewHolderFactoryTest { addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = - answers.first { it.value.displayString(parent.context) == "Test1 Code" }.valueCoding + answers + .first { it.value.displayString(viewHolder.itemView.context) == "Test1 Code" } + .valueCoding }, ) addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = - answers.first { it.value.displayString(parent.context) == "Test2 Code" }.valueCoding + answers + .first { it.value.displayString(viewHolder.itemView.context) == "Test2 Code" } + .valueCoding }, ) }, @@ -149,8 +182,7 @@ class AutoCompleteViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.chipContainer).childCount) - .isEqualTo(2) + composeTestRule.onAllNodes(hasTestTag(MULTI_AUTO_COMPLETE_INPUT_CHIP_TAG)).assertCountEquals(2) } @Test @@ -198,7 +230,7 @@ class AutoCompleteViewHolderFactoryTest { value = answers .first { - it.value.identifierString(parent.context) == + it.value.identifierString(viewHolder.itemView.context) == "http://answers/test-codes1.0|test2-code" } .valueCoding @@ -211,8 +243,7 @@ class AutoCompleteViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.chipContainer).childCount) - .isEqualTo(2) + composeTestRule.onAllNodes(hasTestTag(MULTI_AUTO_COMPLETE_INPUT_CHIP_TAG)).assertCountEquals(2) } @Test @@ -245,7 +276,9 @@ class AutoCompleteViewHolderFactoryTest { addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = - answers.first { it.value.displayString(parent.context) == "Test1 Code" }.valueCoding + answers + .first { it.value.displayString(viewHolder.itemView.context) == "Test1 Code" } + .valueCoding }, ) }, @@ -255,8 +288,7 @@ class AutoCompleteViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.chipContainer).childCount) - .isEqualTo(1) + composeTestRule.onAllNodes(hasTestTag(MULTI_AUTO_COMPLETE_INPUT_CHIP_TAG)).assertCountEquals(1) } @Test @@ -284,8 +316,10 @@ class AutoCompleteViewHolderFactoryTest { ), ) - assertThat((viewHolder.itemView.findViewById(R.id.chipContainer)[0] as Chip).text) - .isEqualTo("test1-code") + composeTestRule + .onAllNodes(hasTestTag(MULTI_AUTO_COMPLETE_INPUT_CHIP_TAG)) + .onFirst() + .assertTextEquals("test1-code") } @Test @@ -299,12 +333,14 @@ class AutoCompleteViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.error).visibility) - .isEqualTo(View.VISIBLE) - assertThat(viewHolder.itemView.findViewById(R.id.error).text) - .isEqualTo("Missing answer for required field.") - assertThat(viewHolder.itemView.findViewById(R.id.text_input_layout).error) - .isNotNull() + composeTestRule + .onNodeWithTag(MULTI_AUTO_COMPLETE_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.Error, + "Missing answer for required field.", + ), + ) } @Test @@ -331,14 +367,17 @@ class AutoCompleteViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.error).visibility) - .isEqualTo(View.GONE) - assertThat(viewHolder.itemView.findViewById(R.id.text_input_layout).error) - .isNull() + composeTestRule + .onNodeWithTag(MULTI_AUTO_COMPLETE_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.keyNotDefined( + SemanticsProperties.Error, + ), + ) } @Test - fun `hides error textview in the header`() { + fun hidesErrorTextviewInTheHeader() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), @@ -348,12 +387,14 @@ class AutoCompleteViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.error_text_at_header).visibility) - .isEqualTo(View.GONE) + composeTestRule + .onNodeWithTag(ERROR_TEXT_AT_HEADER_TEST_TAG) + .assertIsNotDisplayed() + .assertDoesNotExist() } @Test - fun `show asterisk`() { + fun showAsterisk() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -367,12 +408,15 @@ class AutoCompleteViewHolderFactoryTest { ), ) + // Synchronize + composeTestRule.waitForIdle() + assertThat(viewHolder.itemView.findViewById(R.id.question).text.toString()) .isEqualTo("Question *") } @Test - fun `hide asterisk`() { + fun hideAsterisk() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -386,12 +430,15 @@ class AutoCompleteViewHolderFactoryTest { ), ) + // Synchronize + composeTestRule.waitForIdle() + assertThat(viewHolder.itemView.findViewById(R.id.question).text.toString()) .isEqualTo("Question") } @Test - fun `shows required text`() { + fun showsRequiredText() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { required = true }, @@ -402,14 +449,14 @@ class AutoCompleteViewHolderFactoryTest { ), ) - assertThat( - viewHolder.itemView.findViewById(R.id.required_optional_text).text.toString(), - ) - .isEqualTo("Required") + composeTestRule + .onNodeWithTag(REQUIRED_OPTIONAL_HEADER_TEXT_TAG) + .assertIsDisplayed() + .assertTextEquals("Required") } @Test - fun `hide required text`() { + fun hideRequiredText() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { required = true }, @@ -420,16 +467,14 @@ class AutoCompleteViewHolderFactoryTest { ), ) - assertThat( - viewHolder.itemView.findViewById(R.id.required_optional_text).text.toString(), - ) - .isEmpty() - assertThat(viewHolder.itemView.findViewById(R.id.required_optional_text).visibility) - .isEqualTo(View.GONE) + composeTestRule + .onNodeWithTag(REQUIRED_OPTIONAL_HEADER_TEXT_TAG) + .assertIsNotDisplayed() + .assertDoesNotExist() } @Test - fun `shows optional text`() { + fun showsOptionalText() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { text = "Question" }, @@ -440,14 +485,14 @@ class AutoCompleteViewHolderFactoryTest { ), ) - assertThat( - viewHolder.itemView.findViewById(R.id.required_optional_text).text.toString(), - ) - .isEqualTo("Optional") + composeTestRule + .onNodeWithTag(REQUIRED_OPTIONAL_HEADER_TEXT_TAG) + .assertIsDisplayed() + .assertTextEquals("Optional") } @Test - fun `hide optional text`() { + fun hideOptionalText() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { text = "Question" }, @@ -458,11 +503,9 @@ class AutoCompleteViewHolderFactoryTest { ), ) - assertThat( - viewHolder.itemView.findViewById(R.id.required_optional_text).text.toString(), - ) - .isEmpty() - assertThat(viewHolder.itemView.findViewById(R.id.required_optional_text).visibility) - .isEqualTo(View.GONE) + composeTestRule + .onNodeWithTag(REQUIRED_OPTIONAL_HEADER_TEXT_TAG) + .assertIsNotDisplayed() + .assertDoesNotExist() } } diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/DatePickerViewHolderFactoryTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DatePickerViewHolderFactoryTest.kt similarity index 68% rename from datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/DatePickerViewHolderFactoryTest.kt rename to datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DatePickerViewHolderFactoryTest.kt index be8ddf3167..9a3cd25245 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/DatePickerViewHolderFactoryTest.kt +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DatePickerViewHolderFactoryTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 Google LLC + * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,42 +14,72 @@ * limitations under the License. */ -package com.google.android.fhir.datacapture.views.factories +package com.google.android.fhir.datacapture.test.views -import android.view.View import android.widget.FrameLayout import android.widget.TextView -import androidx.appcompat.app.AppCompatActivity +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertTextContains +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.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTextInput +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.EXTENSION_ENTRY_FORMAT_URL +import com.google.android.fhir.datacapture.extensions.toAnnotatedString +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.QuestionTextConfiguration import com.google.android.fhir.datacapture.views.QuestionnaireViewItem -import com.google.android.material.textfield.TextInputLayout +import com.google.android.fhir.datacapture.views.compose.DATE_TEXT_INPUT_FIELD +import com.google.android.fhir.datacapture.views.compose.ERROR_TEXT_AT_HEADER_TEST_TAG +import com.google.android.fhir.datacapture.views.factories.DatePickerViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder import com.google.common.truth.Truth.assertThat -import java.time.chrono.IsoChronology -import java.time.format.DateTimeFormatterBuilder -import java.time.format.FormatStyle import java.util.Locale -import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.runBlocking import org.hl7.fhir.r4.model.DateType import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.StringType +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 DatePickerViewHolderFactoryTest { - private val context = - Robolectric.buildActivity(AppCompatActivity::class.java).create().get().apply { - setTheme(com.google.android.material.R.style.Theme_Material3_DayNight) + @get:Rule + val activityScenarioRule: ActivityScenarioRule = + ActivityScenarioRule(TestActivity::class.java) + + @get:Rule val composeTestRule = createEmptyComposeRule() + + private lateinit var viewHolder: QuestionnaireItemViewHolder + private lateinit var parent: FrameLayout + + @Before + fun setUp() { + activityScenarioRule.scenario.onActivity { activity -> + parent = FrameLayout(activity) + viewHolder = DatePickerViewHolderFactory.create(parent) + activity.setContentView(viewHolder.itemView) } - private val parent = FrameLayout(context) - private val viewHolder = DatePickerViewHolderFactory.create(parent) + + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + } @Test fun shouldSetQuestionHeader() { @@ -62,6 +92,9 @@ class DatePickerViewHolderFactoryTest { ), ) + // Synchronize + composeTestRule.waitForIdle() + assertThat(viewHolder.itemView.findViewById(R.id.question).text.toString()) .isEqualTo("Question?") } @@ -77,11 +110,13 @@ class DatePickerViewHolderFactoryTest { ), ) - assertThat(viewHolder.dateInputView.text.toString()).isEqualTo("") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals("") } @Test - fun `should set text field empty when date field is initialized but answer date value is null`() { + fun shouldSetTextFieldEmptyWhenDateFieldIsInitializedButAnswerDateValueIsNull() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { text = "Question?" }, @@ -94,10 +129,9 @@ class DatePickerViewHolderFactoryTest { ), ) - assertThat( - viewHolder.itemView.findViewById(R.id.text_input_edit_text).text.toString(), - ) - .isEqualTo("") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals("") } @Test @@ -115,11 +149,13 @@ class DatePickerViewHolderFactoryTest { answersChangedCallback = { _, _, _, _ -> }, ), ) - assertThat(viewHolder.dateInputView.text.toString()).isEqualTo("11/19/2020") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals("11/19/2020") } @Test - fun `show dateFormat label in lowerCase`() { + fun showDateFormatLabelInLowerCase() { setLocale(Locale.US) viewHolder.bind( QuestionnaireViewItem( @@ -129,13 +165,14 @@ class DatePickerViewHolderFactoryTest { answersChangedCallback = { _, _, _, _ -> }, ), ) - assertThat(viewHolder.dateInputView.hint.toString()).isEqualTo("mm/dd/yyyy") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .assertTextEquals("mm/dd/yyyy", includeEditableText = false) } @Test fun shouldSetDateInput_localeJp() { setLocale(Locale.JAPAN) - val viewHolder = DatePickerViewHolderFactory.create(parent) viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { text = "Question?" }, @@ -148,7 +185,9 @@ class DatePickerViewHolderFactoryTest { answersChangedCallback = { _, _, _, _ -> }, ), ) - assertThat(viewHolder.dateInputView.text.toString()).isEqualTo("2020/11/19") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals("2020/11/19") } @Test @@ -166,11 +205,13 @@ class DatePickerViewHolderFactoryTest { answersChangedCallback = { _, _, _, _ -> }, ), ) - assertThat(viewHolder.dateInputView.text.toString()).isEqualTo("11/19/2020") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals("11/19/2020") } @Test - fun `parse date text input in US locale`() { + fun parseDateTextInputInUsLocale() { setLocale(Locale.US) var answers: List? = null val item = @@ -182,7 +223,9 @@ class DatePickerViewHolderFactoryTest { ) viewHolder.bind(item) - viewHolder.dateInputView.text = "11/19/2020" + val dateTextInput = "11192020" // is transformed to 11/19/2020 in the date widget + composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextInput(dateTextInput) + composeTestRule.waitUntil { answers != null } val answer = answers!!.single().value as DateType @@ -192,9 +235,8 @@ class DatePickerViewHolderFactoryTest { } @Test - fun `parse date text input in Japan locale`() { + fun parseDateTextInputInJapanLocale() { setLocale(Locale.JAPAN) - val viewHolder = DatePickerViewHolderFactory.create(parent) var answers: List? = null val item = QuestionnaireViewItem( @@ -204,7 +246,8 @@ class DatePickerViewHolderFactoryTest { answersChangedCallback = { _, _, result, _ -> answers = result }, ) viewHolder.bind(item) - viewHolder.dateInputView.text = "2020/11/19" + composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextInput("20201119") + composeTestRule.waitUntil { answers != null } val answer = answers!!.single().value as DateType assertThat(answer.day).isEqualTo(19) @@ -213,7 +256,7 @@ class DatePickerViewHolderFactoryTest { } @Test - fun `clear the answer if date input is invalid`() { + fun clearTheAnswerIfDateInputIsInvalid() { setLocale(Locale.US) var answers: List? = null val questionnaireItem = @@ -228,13 +271,22 @@ class DatePickerViewHolderFactoryTest { answersChangedCallback = { _, _, result, _ -> answers = result }, ) viewHolder.bind(questionnaireItem) + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals("11/19/2020") + val dateTextInput = "1119" // transforms to 11/19 in the datePicker widget + composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).performSemanticsAction( + SemanticsActions.SetText, + ) { + it(dateTextInput.toAnnotatedString()) + } + composeTestRule.waitUntil { answers != null } - viewHolder.dateInputView.text = "11/19/" assertThat(answers!!).isEmpty() } @Test - fun `do not clear the text field input for invalid date`() { + fun doNotClearTheTextFieldInputForInvalidDate() { setLocale(Locale.US) val questionnaireItem = QuestionnaireViewItem( @@ -248,13 +300,22 @@ class DatePickerViewHolderFactoryTest { answersChangedCallback = { _, _, _, _ -> }, ) viewHolder.bind(questionnaireItem) - - viewHolder.dateInputView.text = "11/19/" - assertThat(viewHolder.dateInputView.text.toString()).isEqualTo("11/19/") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals("11/19/2020") + val dateTextInput = "1119" // transforms to 11/19 in the datePicker widget + composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).performSemanticsAction( + SemanticsActions.SetText, + ) { + it(dateTextInput.toAnnotatedString()) + } + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals("11/19/") } @Test - fun `clear questionnaire response answer on draft answer update`() { + fun clearQuestionnaireResponseAnswerOnDraftAnswerUpdate() { var answers: List? = listOf(QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent()) setLocale(Locale.US) @@ -271,15 +332,12 @@ class DatePickerViewHolderFactoryTest { ) viewHolder.bind(questionnaireItem) - runTest { - questionnaireItem.setDraftAnswer("02/07") - - assertThat(answers!!).isEmpty() - } + runBlocking { questionnaireItem.setDraftAnswer("02/07") } + assertThat(answers!!).isEmpty() } @Test - fun `clear draft value on an valid answer update`() { + fun clearDraftValueOnAnValidAnswerUpdate() { val answer = QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent() .setValue(DateType(2026, 0, 1)) @@ -300,15 +358,12 @@ class DatePickerViewHolderFactoryTest { ) viewHolder.bind(questionnaireItem) - runTest { - questionnaireItem.setAnswer(answer) - - assertThat(partialValue).isNull() - } + runBlocking { questionnaireItem.setAnswer(answer) } + assertThat(partialValue).isNull() } @Test - fun `display partial answer in the text field of recycled items`() { + fun displayPartialAnswerInTheTextFieldOfRecycledItems() { setLocale(Locale.US) var questionnaireItem = QuestionnaireViewItem( @@ -323,7 +378,9 @@ class DatePickerViewHolderFactoryTest { ) viewHolder.bind(questionnaireItem) - assertThat(viewHolder.dateInputView.text.toString()).isEqualTo("11/19/2020") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals("11/19/2020") questionnaireItem = QuestionnaireViewItem( @@ -331,15 +388,17 @@ class DatePickerViewHolderFactoryTest { QuestionnaireResponse.QuestionnaireResponseItemComponent(), validationResult = NotValidated, answersChangedCallback = { _, _, _, _ -> }, - draftAnswer = "02/07", + draftAnswer = "0207", ) viewHolder.bind(questionnaireItem) - assertThat(viewHolder.dateInputView.text.toString()).isEqualTo("02/07") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals("02/07/") } @Test - fun `display an answer in the text field of partially answered recycled item`() { + fun displayAnAnswerInTheTextFieldOfPartiallyAnsweredRecycledItem() { setLocale(Locale.US) var questionnaireItem = QuestionnaireViewItem( @@ -347,11 +406,13 @@ class DatePickerViewHolderFactoryTest { QuestionnaireResponse.QuestionnaireResponseItemComponent(), validationResult = NotValidated, answersChangedCallback = { _, _, _, _ -> }, - draftAnswer = "02/07", + draftAnswer = "0207", ) viewHolder.bind(questionnaireItem) - assertThat(viewHolder.dateInputView.text.toString()).isEqualTo("02/07") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals("02/07/") questionnaireItem = QuestionnaireViewItem( @@ -366,7 +427,9 @@ class DatePickerViewHolderFactoryTest { ) viewHolder.bind(questionnaireItem) - assertThat(viewHolder.dateInputView.text.toString()).isEqualTo("11/19/2020") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals("11/19/2020") } @Test @@ -395,25 +458,37 @@ class DatePickerViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.text_input_layout).error) - .isEqualTo("Maximum value allowed is:2025-01-01") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.Error, + "Maximum value allowed is:2025-01-01", + ), + ) } @Test - fun `show dateFormat in lowerCase in the error message`() { + fun showDateFormatInLowercaseInTheErrorMessage() { val itemViewItem = QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), QuestionnaireResponse.QuestionnaireResponseItemComponent(), validationResult = NotValidated, answersChangedCallback = { _, _, _, _ -> }, - draftAnswer = "11/19/202", + draftAnswer = "1119202", ) viewHolder.bind(itemViewItem) - assertThat(viewHolder.itemView.findViewById(R.id.text_input_layout).error) - .isEqualTo("Date format needs to be mm/dd/yyyy (e.g. 01/31/2023)") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.Error, + "Date format needs to be mm/dd/yyyy (e.g. 01/31/2023)", + ), + ) } @Test @@ -441,12 +516,13 @@ class DatePickerViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.text_input_layout).error) - .isNull() + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .assert(SemanticsMatcher.keyNotDefined(SemanticsProperties.Error)) } @Test - fun `hides error textview in the header`() { + fun hidesErrorTextviewInTheHeader() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), @@ -456,8 +532,10 @@ class DatePickerViewHolderFactoryTest { ), ) - 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 @@ -471,11 +549,14 @@ class DatePickerViewHolderFactoryTest { ), ) - assertThat(viewHolder.dateInputView.isEnabled).isFalse() + composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).assertIsNotEnabled() + composeTestRule + .onNodeWithContentDescription(viewHolder.itemView.context.getString(R.string.select_date)) + .assertIsNotEnabled() } @Test - fun `bind multiple times with different QuestionnaireItemViewItem should show proper date`() { + fun bindMultipleTimesWithDifferentQuestionnaireItemViewItemShouldShowProperDate() { setLocale(Locale.US) viewHolder.bind( @@ -490,7 +571,9 @@ class DatePickerViewHolderFactoryTest { answersChangedCallback = { _, _, _, _ -> }, ), ) - assertThat(viewHolder.dateInputView.text.toString()).isEqualTo("11/19/2020") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals("11/19/2020") viewHolder.bind( QuestionnaireViewItem( @@ -504,7 +587,9 @@ class DatePickerViewHolderFactoryTest { answersChangedCallback = { _, _, _, _ -> }, ), ) - assertThat(viewHolder.dateInputView.text.toString()).isEqualTo("11/19/2021") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals("11/19/2021") viewHolder.bind( QuestionnaireViewItem( @@ -514,11 +599,13 @@ class DatePickerViewHolderFactoryTest { answersChangedCallback = { _, _, _, _ -> }, ), ) - assertThat(viewHolder.dateInputView.text.toString()).isEmpty() + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals("") } @Test - fun `should use date format in the entryFormat extension`() { + fun shouldUseDateFormatInTheEntryFormatExtension() { setLocale(Locale.US) viewHolder.bind( QuestionnaireViewItem( @@ -530,11 +617,13 @@ class DatePickerViewHolderFactoryTest { answersChangedCallback = { _, _, _, _ -> }, ), ) - assertThat(viewHolder.dateInputView.hint).isEqualTo("yyyy-mm-dd") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .assertTextEquals("yyyy-mm-dd", includeEditableText = false) } @Test - fun `should set local date input format when entryFormat extension has incorrect format string in Questionnaire`() { + fun shouldSetLocalDateInputFormatWhenEntryFormatExtensionHasIncorrectFormatStringInQuestionnaire() { setLocale(Locale.US) viewHolder.bind( QuestionnaireViewItem( @@ -546,11 +635,13 @@ class DatePickerViewHolderFactoryTest { answersChangedCallback = { _, _, _, _ -> }, ), ) - assertThat(viewHolder.dateInputView.hint).isEqualTo("mm/dd/yyyy") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .assertTextEquals("mm/dd/yyyy", includeEditableText = false) } @Test - fun `should use date format in the entryFormat extension though date separator is missing`() { + fun shouldUseDateFormatInTheEntryFormatExtensionThoughDateSeparatorIsMissing() { setLocale(Locale.US) viewHolder.bind( QuestionnaireViewItem( @@ -562,11 +653,13 @@ class DatePickerViewHolderFactoryTest { answersChangedCallback = { _, _, _, _ -> }, ), ) - assertThat(viewHolder.dateInputView.hint).isEqualTo("yyyymmdd") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .assertTextEquals("yyyymmdd", includeEditableText = false) } @Test - fun `should use date format in the entryFormat after converting it to SHORT FormatStyle`() { + fun shouldUseDateFormatInTheEntryFormatAfterConvertingItToShortFormatStyle() { setLocale(Locale.US) viewHolder.bind( QuestionnaireViewItem( @@ -578,11 +671,13 @@ class DatePickerViewHolderFactoryTest { answersChangedCallback = { _, _, _, _ -> }, ), ) - assertThat(viewHolder.dateInputView.hint).isEqualTo("yyyy mm dd") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .assertTextEquals("yyyy mm dd", includeEditableText = false) } @Test - fun `should set local date input format when entryFormat extension has empty string in Questionnaire`() { + fun shouldSetLocalDateInputFormatWhenEntryFormatExtensionHasEmptyStringInQuestionnaire() { setLocale(Locale.US) viewHolder.bind( QuestionnaireViewItem( @@ -594,18 +689,13 @@ class DatePickerViewHolderFactoryTest { answersChangedCallback = { _, _, _, _ -> }, ), ) - val localeDatePattern = - DateTimeFormatterBuilder.getLocalizedDateTimePattern( - FormatStyle.SHORT, - null, - IsoChronology.INSTANCE, - Locale.getDefault(), - ) - assertThat(viewHolder.dateInputView.hint).isEqualTo("mm/dd/yyyy") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .assertTextEquals("mm/dd/yyyy", includeEditableText = false) } @Test - fun `should set local date input format when no entryFormat extension in Questionnaire`() { + fun shouldSetLocalDateInputFormatWhenNoEntryFormatExtensionInQuestionnaire() { setLocale(Locale.US) viewHolder.bind( QuestionnaireViewItem( @@ -615,18 +705,13 @@ class DatePickerViewHolderFactoryTest { answersChangedCallback = { _, _, _, _ -> }, ), ) - val localeDatePattern = - DateTimeFormatterBuilder.getLocalizedDateTimePattern( - FormatStyle.SHORT, - null, - IsoChronology.INSTANCE, - Locale.getDefault(), - ) - assertThat(viewHolder.dateInputView.hint).isEqualTo("mm/dd/yyyy") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .assertTextEquals("mm/dd/yyyy", includeEditableText = false) } @Test - fun `show asterisk`() { + fun showAsterisk() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -640,12 +725,15 @@ class DatePickerViewHolderFactoryTest { ), ) + // Synchronize + composeTestRule.waitForIdle() + assertThat(viewHolder.itemView.findViewById(R.id.question).text.toString()) .isEqualTo("Question? *") } @Test - fun `hide asterisk`() { + fun hideAsterisk() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -658,13 +746,15 @@ class DatePickerViewHolderFactoryTest { questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = false), ), ) + // Synchronize + composeTestRule.waitForIdle() assertThat(viewHolder.itemView.findViewById(R.id.question).text.toString()) .isEqualTo("Question?") } @Test - fun `show required text`() { + fun showRequiredText() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { required = true }, @@ -675,17 +765,11 @@ class DatePickerViewHolderFactoryTest { ), ) - assertThat( - viewHolder.itemView - .findViewById(R.id.text_input_layout) - .helperText - .toString(), - ) - .isEqualTo("Required") + composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).assertTextContains("Required") } @Test - fun `hide required text`() { + fun hideRequiredText() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { required = true }, @@ -696,12 +780,11 @@ class DatePickerViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.text_input_layout).helperText) - .isNull() + composeTestRule.onNodeWithText("Required").assertIsNotDisplayed().assertDoesNotExist() } @Test - fun `shows optional text`() { + fun showsOptionalText() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), @@ -712,17 +795,11 @@ class DatePickerViewHolderFactoryTest { ), ) - assertThat( - viewHolder.itemView - .findViewById(R.id.text_input_layout) - .helperText - .toString(), - ) - .isEqualTo("Optional") + composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).assertTextContains("Optional") } @Test - fun `hide optional text`() { + fun hideOptionalText() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), @@ -733,17 +810,11 @@ class DatePickerViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.text_input_layout).helperText) - .isNull() + composeTestRule.onNodeWithText("Optional").assertIsNotDisplayed().assertDoesNotExist() } private fun setLocale(locale: Locale) { Locale.setDefault(locale) - context.resources.configuration.setLocale(locale) + parent.context.resources.configuration.setLocale(locale) } - - private val QuestionnaireItemViewHolder.dateInputView: TextView - get() { - return itemView.findViewById(R.id.text_input_edit_text) - } } diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DateTimePickerViewHolderFactoryEspressoTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DateTimePickerViewHolderFactoryEspressoTest.kt index 43b4a67172..8079c1e13e 100644 --- a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DateTimePickerViewHolderFactoryEspressoTest.kt +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DateTimePickerViewHolderFactoryEspressoTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 2023-2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,26 +16,30 @@ package com.google.android.fhir.datacapture.test.views -import android.view.View import android.widget.FrameLayout -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions -import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.matcher.RootMatchers.isDialog -import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.filterToOne +import androidx.compose.ui.test.hasAnyChild +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.isEditable +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onChildren +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick 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.test.utilities.clickIcon import com.google.android.fhir.datacapture.validation.NotValidated import com.google.android.fhir.datacapture.views.QuestionnaireViewItem +import com.google.android.fhir.datacapture.views.compose.DATE_TEXT_INPUT_FIELD +import com.google.android.fhir.datacapture.views.compose.TIME_PICKER_INPUT_FIELD import com.google.android.fhir.datacapture.views.factories.DateTimePickerViewHolderFactory import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder -import org.hamcrest.CoreMatchers.allOf import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse import org.junit.Before @@ -51,14 +55,17 @@ class DateTimePickerViewHolderFactoryEspressoTest { var activityScenarioRule: ActivityScenarioRule = ActivityScenarioRule(TestActivity::class.java) - private lateinit var parent: FrameLayout + @get:Rule val composeTestRule = createEmptyComposeRule() + private lateinit var viewHolder: QuestionnaireItemViewHolder @Before fun setup() { - activityScenarioRule.scenario.onActivity { activity -> parent = FrameLayout(activity) } - viewHolder = DateTimePickerViewHolderFactory.create(parent) - setTestLayout(viewHolder.itemView) + activityScenarioRule.scenario.onActivity { activity -> + viewHolder = DateTimePickerViewHolderFactory.create(FrameLayout(activity)) + activity.setContentView(viewHolder.itemView) + } + InstrumentationRegistry.getInstrumentation().waitForIdleSync() } @Test @@ -71,17 +78,29 @@ class DateTimePickerViewHolderFactoryEspressoTest { answersChangedCallback = { _, _, _, _ -> }, ) - runOnUI { viewHolder.bind(questionnaireItemView) } - onView(withId(R.id.date_input_layout)).perform(clickIcon(true)) - onView(allOf(withText("OK"))) - .inRoot(isDialog()) - .check(matches(isDisplayed())) - .perform(ViewActions.click()) - onView(withId(R.id.time_input_edit_text)).perform(ViewActions.click()) - // R.id.material_textinput_timepicker is the id for the text input in the time picker. - onView(allOf(withId(com.google.android.material.R.id.material_textinput_timepicker))) - .inRoot(isDialog()) - .check(matches(isDisplayed())) + viewHolder.bind(questionnaireItemView) + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .onChildren() + .filterToOne( + SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button), + ) + .performClick() + composeTestRule.onNodeWithText("OK").performClick() + composeTestRule.onNodeWithTag(TIME_PICKER_INPUT_FIELD).performClick() + + composeTestRule + .onNode( + hasContentDescription("Switch to clock input", substring = true) and + SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button), + ) + .assertIsDisplayed() + composeTestRule + .onNode(hasContentDescription("for hour", substring = true) and isEditable()) + .assertIsDisplayed() + composeTestRule + .onNode(hasContentDescription("for minutes", substring = true) and isEditable()) + .assertExists() } @Test @@ -94,27 +113,34 @@ class DateTimePickerViewHolderFactoryEspressoTest { answersChangedCallback = { _, _, _, _ -> }, ) - runOnUI { viewHolder.bind(questionnaireItemView) } - onView(withId(R.id.date_input_layout)).perform(clickIcon(true)) - onView(allOf(withText("OK"))) - .inRoot(isDialog()) - .check(matches(isDisplayed())) - .perform(ViewActions.click()) - onView(withId(R.id.time_input_layout)).perform(clickIcon(true)) - // R.id.material_clock_face is the id for the clock input in the time picker. - onView(allOf(withId(com.google.android.material.R.id.material_clock_face))) - .inRoot(isDialog()) - .check(matches(isDisplayed())) - } - - /** Method to run code snippet on UI/main thread */ - private fun runOnUI(action: () -> Unit) { - activityScenarioRule.scenario.onActivity { activity -> action() } - } + viewHolder.bind(questionnaireItemView) + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .onChildren() + .filterToOne( + SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button), + ) + .performClick() + composeTestRule.onNodeWithText("OK").performClick() + composeTestRule + .onNodeWithTag(TIME_PICKER_INPUT_FIELD) + .onChildren() + .filterToOne( + SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button), + ) + .performClick() - /** Method to set content view for test activity */ - private fun setTestLayout(view: View) { - activityScenarioRule.scenario.onActivity { activity -> activity.setContentView(view) } - InstrumentationRegistry.getInstrumentation().waitForIdleSync() + composeTestRule + .onNode( + hasContentDescription("Switch to text input", substring = true) and + SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button), + ) + .assertIsDisplayed() + composeTestRule + .onNode( + hasAnyChild(hasContentDescription("12 o'clock", substring = true)) and + SemanticsMatcher.keyIsDefined(SemanticsProperties.SelectableGroup), + ) + .assertIsDisplayed() } } diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/DateTimePickerViewHolderFactoryTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DateTimePickerViewHolderFactoryTest.kt similarity index 62% rename from datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/DateTimePickerViewHolderFactoryTest.kt rename to datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DateTimePickerViewHolderFactoryTest.kt index 97f3e046ae..747ca57e2c 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/DateTimePickerViewHolderFactoryTest.kt +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DateTimePickerViewHolderFactoryTest.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,45 +14,67 @@ * 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.text.format.DateFormat import android.widget.FrameLayout import android.widget.TextView -import androidx.appcompat.app.AppCompatActivity +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTextReplacement +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry import com.google.android.fhir.datacapture.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.QuestionTextConfiguration import com.google.android.fhir.datacapture.views.QuestionnaireViewItem -import com.google.android.material.textfield.TextInputLayout +import com.google.android.fhir.datacapture.views.compose.DATE_TEXT_INPUT_FIELD +import com.google.android.fhir.datacapture.views.compose.ERROR_TEXT_AT_HEADER_TEST_TAG +import com.google.android.fhir.datacapture.views.compose.TIME_PICKER_INPUT_FIELD +import com.google.android.fhir.datacapture.views.factories.DateTimePickerViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder import com.google.common.truth.Truth.assertThat import java.util.Date import java.util.Locale -import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.runBlocking import org.hl7.fhir.r4.model.DateTimeType 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 DateTimePickerViewHolderFactoryTest { - 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 = DateTimePickerViewHolderFactory.create(parent) + @get:Rule + val activityScenarioRule: ActivityScenarioRule = + ActivityScenarioRule(TestActivity::class.java) + + @get:Rule val composeTestRule = createEmptyComposeRule() + + private lateinit var viewHolder: QuestionnaireItemViewHolder @Before fun setUp() { Locale.setDefault(Locale.US) - org.robolectric.shadows.ShadowSettings.set24HourTimeFormat(false) + activityScenarioRule.scenario.onActivity { activity -> + viewHolder = DateTimePickerViewHolderFactory.create(FrameLayout(activity)) + activity.setContentView(viewHolder.itemView) + } + + InstrumentationRegistry.getInstrumentation().waitForIdleSync() } @Test @@ -65,8 +87,12 @@ class DateTimePickerViewHolderFactoryTest { answersChangedCallback = { _, _, _, _ -> }, ), ) + // Synchronize + composeTestRule.waitForIdle() - assertThat(viewHolder.itemView.findViewById(R.id.question).text.toString()) + assertThat( + viewHolder.itemView.findViewById(R.id.question).text.toString(), + ) .isEqualTo("Question?") } @@ -81,12 +107,16 @@ class DateTimePickerViewHolderFactoryTest { ), ) - assertThat(viewHolder.dateInputView.text.toString()).isEqualTo("") - assertThat(viewHolder.timeInputView.text.toString()).isEqualTo("") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals("") + composeTestRule + .onNodeWithTag(TIME_PICKER_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals("") } @Test - fun `show dateFormat label in lowerCase`() { + fun showDateFormatLabelInLowerCase() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), @@ -95,7 +125,9 @@ class DateTimePickerViewHolderFactoryTest { answersChangedCallback = { _, _, _, _ -> }, ), ) - assertThat(viewHolder.dateInputView.hint.toString()).isEqualTo("mm/dd/yyyy") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .assertTextEquals("mm/dd/yyyy", includeEditableText = false) } @Test @@ -113,13 +145,19 @@ class DateTimePickerViewHolderFactoryTest { ), ) - assertThat(viewHolder.dateInputView.text.toString()).isEqualTo("02/05/2020") - assertThat(viewHolder.timeInputView.text.toString()).isEqualTo("1:30 AM") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals("02/05/2020") + val is24Hour = DateFormat.is24HourFormat(viewHolder.itemView.context) + val expectedTime = if (is24Hour) "01:30" else "1:30 AM" + composeTestRule + .onNodeWithTag(TIME_PICKER_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals(expectedTime) } @Test - fun `parse date text input in US locale`() { - var draftAnswer: Any? = null + fun parseDateTextInputInUSLocale() { + var answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent? = null val itemViewItem = QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { text = "Question?" }, @@ -129,18 +167,25 @@ class DateTimePickerViewHolderFactoryTest { .setValue(DateTimeType(Date(2020 - 1900, 1, 5, 1, 30, 0))), ), validationResult = NotValidated, - answersChangedCallback = { _, _, _, result -> draftAnswer = result }, + answersChangedCallback = { _, _, result, _ -> answer = result.singleOrNull() }, ) viewHolder.bind(itemViewItem) - viewHolder.dateInputView.text = "11/19/2020" - assertThat(draftAnswer as String).isEqualTo("11/19/2020") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .performTextReplacement("11192020") // transforms to 11/19/2020 in the date widget + composeTestRule.waitUntil { answer != null } + + val dateTime = answer!!.value as DateTimeType + assertThat(dateTime.day).isEqualTo(19) + assertThat(dateTime.month).isEqualTo(10) + assertThat(dateTime.year).isEqualTo(2020) } @Test - fun `parse date text input in Japan locale`() { + fun parseDateTextInputInJapanLocale() { Locale.setDefault(Locale.JAPAN) - var draftAnswer: Any? = null + var answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent? = null val itemViewItem = QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { text = "Question?" }, @@ -150,18 +195,26 @@ class DateTimePickerViewHolderFactoryTest { .setValue(DateTimeType(Date(2020 - 1900, 1, 5, 1, 30, 0))), ), validationResult = NotValidated, - answersChangedCallback = { _, _, _, result -> draftAnswer = result }, + answersChangedCallback = { _, _, result, _ -> answer = result.singleOrNull() }, ) viewHolder.bind(itemViewItem) - viewHolder.dateInputView.text = "2020/11/19" + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .performTextReplacement("20201119") // transforms to 2020/11/19 in the date widget + composeTestRule.waitUntil { answer != null } - assertThat(draftAnswer as String).isEqualTo("2020/11/19") + val dateTime = answer!!.value as DateTimeType + assertThat(dateTime.day).isEqualTo(19) + assertThat(dateTime.month).isEqualTo(10) + assertThat(dateTime.year).isEqualTo(2020) } @Test - fun `if date input is invalid then clear the answer`() { + fun ifDateInputIsInvalidThenClearTheAnswer() { + Locale.setDefault(Locale.JAPAN) var answers: List? = null + var draftAnswer: Any? = null val itemViewItem = QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { text = "Question?" }, @@ -171,16 +224,25 @@ class DateTimePickerViewHolderFactoryTest { .setValue(DateTimeType(Date(2020 - 1900, 1, 5, 1, 30, 0))), ), validationResult = NotValidated, - answersChangedCallback = { _, _, result, _ -> answers = result }, + answersChangedCallback = { _, _, result, draft -> + answers = result + draftAnswer = draft + }, ) viewHolder.bind(itemViewItem) - viewHolder.dateInputView.text = "2020/11/" + + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .performTextReplacement("202011") // transforms to 2020/11 for Locale.JAPAN + composeTestRule.waitUntil { answers != null } assertThat(answers!!).isEmpty() + assertThat(draftAnswer as String).isEqualTo("202011") } @Test - fun `do not clear the textField input on invalid date`() { + fun doNotClearTheTextFieldInputOnInvalidDate() { + Locale.setDefault(Locale.JAPAN) val itemViewItem = QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { text = "Question?" }, @@ -193,13 +255,17 @@ class DateTimePickerViewHolderFactoryTest { answersChangedCallback = { _, _, _, _ -> }, ) viewHolder.bind(itemViewItem) - viewHolder.dateInputView.text = "2020/11/" + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .performTextReplacement("202011") // transforms to 2020/11 for Locale.JAPAN - assertThat(viewHolder.dateInputView.text.toString()).isEqualTo("2020/11/") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals("2020/11/") } @Test - fun `clear questionnaire response answer on draft answer update`() { + fun clearQuestionnaireResponseAnswerOnDraftAnswerUpdate() { var answers: List? = listOf(QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent()) val questionnaireItem = @@ -215,15 +281,14 @@ class DateTimePickerViewHolderFactoryTest { ) viewHolder.bind(questionnaireItem) - runTest { - questionnaireItem.setDraftAnswer("02/07") - - assertThat(answers!!).isEmpty() - } + runBlocking { + questionnaireItem.setDraftAnswer("0207") + } // would transform to 02/07/ for default locale + assertThat(answers!!).isEmpty() } @Test - fun `clear draft answer on an valid answer update`() { + fun clearDraftAnswerOnAnValidAnswerUpdate() { val answer = QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent() .setValue(DateTimeType(Date(2020 - 1900, 2, 6, 2, 30, 0))) @@ -241,15 +306,12 @@ class DateTimePickerViewHolderFactoryTest { ) viewHolder.bind(questionnaireItem) - runTest { - questionnaireItem.setAnswer(answer) - - assertThat(draft).isNull() - } + runBlocking { questionnaireItem.setAnswer(answer) } + assertThat(draft).isNull() } @Test - fun `display draft answer in the text field of recycled items`() { + fun displayDraftAnswerInTheTextFieldOfRecycledItems() { var questionnaireItem = QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), @@ -263,7 +325,9 @@ class DateTimePickerViewHolderFactoryTest { ) viewHolder.bind(questionnaireItem) - assertThat(viewHolder.dateInputView.text.toString()).isEqualTo("02/05/2020") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals("02/05/2020") questionnaireItem = QuestionnaireViewItem( @@ -271,26 +335,30 @@ class DateTimePickerViewHolderFactoryTest { QuestionnaireResponse.QuestionnaireResponseItemComponent(), validationResult = NotValidated, answersChangedCallback = { _, _, _, _ -> }, - draftAnswer = "02/07", + draftAnswer = "0207", // transforms to 02/07 for default locale ) viewHolder.bind(questionnaireItem) - assertThat(viewHolder.dateInputView.text.toString()).isEqualTo("02/07") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals("02/07/") } @Test - fun `display an answer in the text field of partially answered recycled item`() { + fun displayAnAnswerInTheTextFieldOfPartiallyAnsweredRecycledItem() { var questionnaireItem = QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), QuestionnaireResponse.QuestionnaireResponseItemComponent(), validationResult = NotValidated, answersChangedCallback = { _, _, _, _ -> }, - draftAnswer = "02/07", + draftAnswer = "0207", // transforms to 02/07 for default locale ) viewHolder.bind(questionnaireItem) - assertThat(viewHolder.dateInputView.text.toString()).isEqualTo("02/07") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals("02/07/") questionnaireItem = QuestionnaireViewItem( @@ -305,28 +373,28 @@ class DateTimePickerViewHolderFactoryTest { ) viewHolder.bind(questionnaireItem) - assertThat(viewHolder.dateInputView.text.toString()).isEqualTo("02/05/2020") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals("02/05/2020") } @Test - fun `if draft answer input is invalid then do not enable time text input layout`() { + fun ifDraftAnswerInputIsInvalidThenDoNotEnableTimeTextInputLayout() { val itemViewItem = QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { text = "Question?" }, QuestionnaireResponse.QuestionnaireResponseItemComponent(), validationResult = NotValidated, answersChangedCallback = { _, _, _, _ -> }, - draftAnswer = "11/19/", + draftAnswer = "1119", // would transform to 11/19/ for default locale ) viewHolder.bind(itemViewItem) - - assertThat(viewHolder.itemView.findViewById(R.id.time_input_layout).isEnabled) - .isFalse() + composeTestRule.onNodeWithTag(TIME_PICKER_INPUT_FIELD).assertIsNotEnabled() } @Test - fun `if the draft answer input is empty, do not enable the time text input layout`() { + fun ifTheDraftAnswerInputIsEmptyDoNotEnableTheTimeTextInputLayout() { val itemViewItem = QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { text = "Question?" }, @@ -337,13 +405,11 @@ class DateTimePickerViewHolderFactoryTest { ) viewHolder.bind(itemViewItem) - - assertThat(viewHolder.itemView.findViewById(R.id.time_input_layout).isEnabled) - .isFalse() + composeTestRule.onNodeWithTag(TIME_PICKER_INPUT_FIELD).assertIsNotEnabled() } @Test - fun `if there is no answer or draft answer, do not enable the time text input layout`() { + fun ifThereIsNoAnswerOrDraftAnswerDoNotEnableTheTimeTextInputLayout() { val itemViewItem = QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { text = "Question?" }, @@ -354,26 +420,22 @@ class DateTimePickerViewHolderFactoryTest { ) viewHolder.bind(itemViewItem) - - assertThat(viewHolder.itemView.findViewById(R.id.time_input_layout).isEnabled) - .isFalse() + composeTestRule.onNodeWithTag(TIME_PICKER_INPUT_FIELD).assertIsNotEnabled() } @Test - fun `if date draft answer is valid then enable time text input layout`() { + fun ifDateDraftAnswerIsValidThenEnableTimeTextInputLayout() { val itemViewItem = QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { text = "Question?" }, QuestionnaireResponse.QuestionnaireResponseItemComponent(), validationResult = NotValidated, answersChangedCallback = { _, _, _, _ -> }, - draftAnswer = "11/19/2020", + draftAnswer = "11192020", // transforms to 11/19/2020 for default locale ) viewHolder.bind(itemViewItem) - - assertThat(viewHolder.itemView.findViewById(R.id.time_input_layout).isEnabled) - .isTrue() + composeTestRule.onNodeWithTag(TIME_PICKER_INPUT_FIELD).assertIsNotEnabled() } @Test @@ -387,8 +449,14 @@ class DateTimePickerViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.date_input_layout).error) - .isEqualTo("Missing answer for required field.") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.Error, + "Missing answer for required field.", + ), + ) } @Test @@ -416,48 +484,61 @@ class DateTimePickerViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.date_input_layout).error) - .isNull() - assertThat(viewHolder.itemView.findViewById(R.id.time_input_layout).error) - .isNull() + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .assert(SemanticsMatcher.keyNotDefined(SemanticsProperties.Error)) + composeTestRule + .onNodeWithTag(TIME_PICKER_INPUT_FIELD) + .assert(SemanticsMatcher.keyNotDefined(SemanticsProperties.Error)) } @Test - fun `if the draft answer is invalid, display the error message`() { + fun ifTheDraftAnswerIsInvalidDisplayTheErrorMessage() { val itemViewItem = QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { text = "Question?" }, QuestionnaireResponse.QuestionnaireResponseItemComponent(), validationResult = NotValidated, answersChangedCallback = { _, _, _, _ -> }, - draftAnswer = "11/19/202", + draftAnswer = "1119202", // transforms to 11/19/202 ) viewHolder.bind(itemViewItem) - assertThat(viewHolder.itemView.findViewById(R.id.date_input_layout).error) - .isEqualTo("Date format needs to be mm/dd/yyyy (e.g. 01/31/2023)") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.Error, + "Date format needs to be mm/dd/yyyy (e.g. 01/31/2023)", + ), + ) } @Test - fun `show dateFormat in lowerCase in the error message`() { + fun showDateFormatInLowerCaseInTheErrorMessage() { val itemViewItem = QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), QuestionnaireResponse.QuestionnaireResponseItemComponent(), validationResult = NotValidated, answersChangedCallback = { _, _, _, _ -> }, - draftAnswer = "11/19/202", + draftAnswer = "1119202", // transforms to 11/19/202 ) viewHolder.bind(itemViewItem) - - assertThat(viewHolder.itemView.findViewById(R.id.date_input_layout).error) - .isEqualTo("Date format needs to be mm/dd/yyyy (e.g. 01/31/2023)") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.Error, + "Date format needs to be mm/dd/yyyy (e.g. 01/31/2023)", + ), + ) } @Test - fun `hides error textview in the header`() { + fun hidesErrorTextviewInTheHeader() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), @@ -467,8 +548,10 @@ class DateTimePickerViewHolderFactoryTest { ), ) - 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 @@ -482,12 +565,12 @@ class DateTimePickerViewHolderFactoryTest { ), ) - assertThat(viewHolder.dateInputView.isEnabled).isFalse() - assertThat(viewHolder.timeInputView.isEnabled).isFalse() + composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).assertIsNotEnabled() + composeTestRule.onNodeWithTag(TIME_PICKER_INPUT_FIELD).assertIsNotEnabled() } @Test - fun `bind multiple times with separate QuestionnaireItemViewItem should show proper date and time`() { + fun bindMultipleTimesWithSeparateQuestionnaireItemViewItemShouldShowProperDateAndTime() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { text = "Question?" }, @@ -501,8 +584,12 @@ class DateTimePickerViewHolderFactoryTest { ), ) - assertThat(viewHolder.dateInputView.text.toString()).isEqualTo("02/05/2020") - assertThat(viewHolder.timeInputView.text.toString()).isEqualTo("1:30 AM") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals("02/05/2020") + composeTestRule + .onNodeWithTag(TIME_PICKER_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals("1:30 AM") viewHolder.bind( QuestionnaireViewItem( @@ -517,8 +604,12 @@ class DateTimePickerViewHolderFactoryTest { ), ) - assertThat(viewHolder.dateInputView.text.toString()).isEqualTo("02/05/2021") - assertThat(viewHolder.timeInputView.text.toString()).isEqualTo("2:30 AM") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals("02/05/2021") + composeTestRule + .onNodeWithTag(TIME_PICKER_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals("2:30 AM") viewHolder.bind( QuestionnaireViewItem( @@ -529,12 +620,16 @@ class DateTimePickerViewHolderFactoryTest { ), ) - assertThat(viewHolder.dateInputView.text.toString()).isEmpty() - assertThat(viewHolder.timeInputView.text.toString()).isEmpty() + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals("") + composeTestRule + .onNodeWithTag(TIME_PICKER_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals("") } @Test - fun `shows asterisk`() { + fun showsAsterisk() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -547,13 +642,17 @@ class DateTimePickerViewHolderFactoryTest { questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = true), ), ) + // Synchronize + composeTestRule.waitForIdle() - assertThat(viewHolder.itemView.findViewById(R.id.question).text.toString()) + assertThat( + viewHolder.itemView.findViewById(R.id.question).text.toString(), + ) .isEqualTo("Question? *") } @Test - fun `hide asterisk`() { + fun hideAsterisk() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -567,12 +666,17 @@ class DateTimePickerViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.question).text.toString()) + // Synchronize + composeTestRule.waitForIdle() + + assertThat( + viewHolder.itemView.findViewById(R.id.question).text.toString(), + ) .isEqualTo("Question?") } @Test - fun `shows required text`() { + fun showsRequiredText() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { required = true }, @@ -582,18 +686,11 @@ class DateTimePickerViewHolderFactoryTest { questionViewTextConfiguration = QuestionTextConfiguration(showRequiredText = true), ), ) - - assertThat( - viewHolder.itemView - .findViewById(R.id.date_input_layout) - .helperText - .toString(), - ) - .isEqualTo("Required") + composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).assertTextContains("Required") } @Test - fun `hide required text`() { + fun hideRequiredText() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { required = true }, @@ -604,12 +701,11 @@ class DateTimePickerViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.date_input_layout).helperText) - .isNull() + composeTestRule.onNodeWithText("Required").assertIsNotDisplayed().assertDoesNotExist() } @Test - fun `shows optional text`() { + fun showsOptionalText() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), @@ -620,17 +716,11 @@ class DateTimePickerViewHolderFactoryTest { ), ) - assertThat( - viewHolder.itemView - .findViewById(R.id.date_input_layout) - .helperText - .toString(), - ) - .isEqualTo("Optional") + composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).assertTextContains("Optional") } @Test - fun `hide optional text`() { + fun hideOptionalText() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), @@ -641,17 +731,6 @@ class DateTimePickerViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.date_input_layout).helperText) - .isNull() + composeTestRule.onNodeWithText("Optional").assertIsNotDisplayed().assertDoesNotExist() } - - private val QuestionnaireItemViewHolder.dateInputView: TextView - get() { - return itemView.findViewById(R.id.date_input_edit_text) - } - - private val QuestionnaireItemViewHolder.timeInputView: TextView - get() { - return itemView.findViewById(R.id.time_input_edit_text) - } } 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/DropDownViewHolderFactoryEspressoTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DropDownViewHolderFactoryEspressoTest.kt index 91abb67b1a..cb3a06699c 100644 --- a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DropDownViewHolderFactoryEspressoTest.kt +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DropDownViewHolderFactoryEspressoTest.kt @@ -16,35 +16,37 @@ 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.onData -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.PerformException -import androidx.test.espresso.action.ViewActions.click -import androidx.test.espresso.action.ViewActions.typeText -import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.matcher.RootMatchers.isPlatformPopup -import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasTextExactly +import androidx.compose.ui.test.isPopup +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.text.AnnotatedString 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.extensions.EXTENSION_ITEM_ANSWER_MEDIA 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.DropDownAnswerOption +import com.google.android.fhir.datacapture.views.compose.CLEAR_TEXT_ICON_BUTTON_TAG +import com.google.android.fhir.datacapture.views.compose.DROP_DOWN_ANSWER_MENU_ITEM_TAG +import com.google.android.fhir.datacapture.views.compose.DROP_DOWN_TEXT_FIELD_LEADING_ICON_TAG +import com.google.android.fhir.datacapture.views.compose.DROP_DOWN_TEXT_FIELD_TAG import com.google.android.fhir.datacapture.views.factories.DropDownViewHolderFactory import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder -import com.google.android.material.textfield.MaterialAutoCompleteTextView import com.google.common.truth.Truth.assertThat -import org.hamcrest.Matchers.instanceOf -import org.hamcrest.Matchers.`is` +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi import org.hl7.fhir.r4.model.Attachment import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.Extension @@ -52,27 +54,29 @@ import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.Reference import org.hl7.fhir.r4.model.StringType -import org.junit.Assert.assertThrows import org.junit.Before import org.junit.Rule import org.junit.Test class DropDownViewHolderFactoryEspressoTest { - @Rule - @JvmField + @get:Rule var activityScenarioRule: ActivityScenarioRule = ActivityScenarioRule(TestActivity::class.java) - private lateinit var parent: FrameLayout + @get:Rule val composeTestRule = createEmptyComposeRule() + private lateinit var viewHolder: QuestionnaireItemViewHolder + + @OptIn(ExperimentalEncodingApi::class) private val itemAnswerMediaExtension = Extension().apply { url = EXTENSION_ITEM_ANSWER_MEDIA setValue( Attachment().apply { data = - "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" - .toByteArray() + Base64.Mime.decode( + "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", + ) contentType = "image/png" }, ) @@ -80,9 +84,12 @@ class DropDownViewHolderFactoryEspressoTest { @Before fun setup() { - activityScenarioRule.scenario.onActivity { activity -> parent = FrameLayout(activity) } - viewHolder = DropDownViewHolderFactory.create(parent) - setTestLayout(viewHolder.itemView) + activityScenarioRule.scenario.onActivity { activity -> + viewHolder = DropDownViewHolderFactory.create(FrameLayout(activity)) + activity.setContentView(viewHolder.itemView) + } + + InstrumentationRegistry.getInstrumentation().waitForIdleSync() } @Test @@ -94,15 +101,25 @@ class DropDownViewHolderFactoryEspressoTest { validationResult = NotValidated, answersChangedCallback = { _, _, _, _ -> }, ) - runOnUI { - viewHolder.bind(questionnaireViewItem) - viewHolder.itemView.findViewById(R.id.auto_complete).showDropDown() - } - onView(withId(R.id.auto_complete)).perform(delayMainThread()) - onView(withText("-")).inRoot(isPlatformPopup()).check(matches(isDisplayed())).perform(click()) - assertThat(viewHolder.itemView.findViewById(R.id.auto_complete).text.toString()) - .isEqualTo("-") + viewHolder.bind(questionnaireViewItem) + composeTestRule.onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() + composeTestRule + .onNode( + hasTestTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG) and + hasTextExactly("-") and + hasAnyAncestor(isPopup()), + ) + .assertIsDisplayed() + .performClick() + composeTestRule + .onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.EditableText, + AnnotatedString("-"), + ), + ) assertThat(questionnaireViewItem.answers).isEmpty() } @@ -116,18 +133,25 @@ class DropDownViewHolderFactoryEspressoTest { validationResult = NotValidated, answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, ) - runOnUI { - viewHolder.bind(questionnaireViewItem) - viewHolder.itemView.findViewById(R.id.auto_complete).showDropDown() - } - - onView(withId(R.id.auto_complete)).perform(delayMainThread()) - onView(withText("Coding 3")) - .inRoot(isPlatformPopup()) - .check(matches(isDisplayed())) - .perform(click()) - assertThat(viewHolder.itemView.findViewById(R.id.auto_complete).text.toString()) - .isEqualTo("Coding 3") + viewHolder.bind(questionnaireViewItem) + composeTestRule.onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() + composeTestRule + .onNode( + hasTestTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG) and + hasTextExactly("Coding 3") and + hasAnyAncestor(isPopup()), + ) + .assertIsDisplayed() + .performClick() + composeTestRule + .onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.EditableText, + AnnotatedString("Coding 3"), + ), + ) + composeTestRule.waitUntil { answerHolder != null } assertThat((answerHolder!!.single().value as Coding).display).isEqualTo("Coding 3") } @@ -141,28 +165,48 @@ class DropDownViewHolderFactoryEspressoTest { validationResult = NotValidated, answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, ) - runOnUI { - viewHolder.bind(questionnaireViewItem) - viewHolder.itemView.findViewById(R.id.auto_complete).showDropDown() - } - onView(withId(R.id.auto_complete)).perform(delayMainThread()) - onView(withText("Coding 3")) - .inRoot(isPlatformPopup()) - .check(matches(isDisplayed())) - .perform(click()) - assertThat(viewHolder.itemView.findViewById(R.id.auto_complete).text.toString()) - .isEqualTo("Coding 3") - - runOnUI { viewHolder.bind(questionnaireViewItem) } - assertThat(viewHolder.itemView.findViewById(R.id.auto_complete).text.toString()) - .isEqualTo("") - val autoCompleteTextView = - viewHolder.itemView.findViewById(R.id.auto_complete) as MaterialAutoCompleteTextView - assertThat(autoCompleteTextView.compoundDrawablesRelative[0]).isNull() - assertThat(autoCompleteTextView.compoundDrawablesRelative[1]).isNull() - assertThat(autoCompleteTextView.compoundDrawablesRelative[2]).isNull() - assertThat(autoCompleteTextView.compoundDrawablesRelative[3]).isNull() + viewHolder.bind(questionnaireViewItem) + composeTestRule.onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() + + composeTestRule + .onNode( + hasTestTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG) and + hasTextExactly("Coding 3") and + hasAnyAncestor(isPopup()), + ) + .assertIsDisplayed() + .performClick() + composeTestRule + .onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.EditableText, + AnnotatedString("Coding 3"), + ), + ) + composeTestRule.waitUntil { answerHolder != null } + val newQuestionnaireResponseItem = responseOptions().apply { answer = answerHolder } + // Bind with QuestionnaireResponse answer updated + viewHolder.bind( + questionnaireViewItem.copy(questionnaireResponseItem = newQuestionnaireResponseItem), + ) + composeTestRule.onNodeWithTag(CLEAR_TEXT_ICON_BUTTON_TAG).assertIsDisplayed() + + // Rebind initial QuestionnaireViewItem + viewHolder.bind(questionnaireViewItem) + composeTestRule + .onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.EditableText, + AnnotatedString(""), + ), + ) + composeTestRule + .onNodeWithTag(DROP_DOWN_TEXT_FIELD_LEADING_ICON_TAG) + .assertIsNotDisplayed() + .assertDoesNotExist() } @Test @@ -182,24 +226,28 @@ class DropDownViewHolderFactoryEspressoTest { validationResult = NotValidated, answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, ) - runOnUI { - viewHolder.bind(questionnaireViewItem) - viewHolder.itemView.findViewById(R.id.auto_complete).showDropDown() - } - - onView(withId(R.id.auto_complete)).perform(delayMainThread()) - onView(withText("Coding 3")) - .inRoot(isPlatformPopup()) - .check(matches(isDisplayed())) - .perform(click()) - assertThat(viewHolder.itemView.findViewById(R.id.auto_complete).text.toString()) - .isEqualTo("Coding 3") - val autoCompleteTextView = - viewHolder.itemView.findViewById(R.id.auto_complete) as MaterialAutoCompleteTextView - assertThat(autoCompleteTextView.compoundDrawablesRelative[0]).isNotNull() - assertThat(autoCompleteTextView.compoundDrawablesRelative[1]).isNull() - assertThat(autoCompleteTextView.compoundDrawablesRelative[2]).isNull() - assertThat(autoCompleteTextView.compoundDrawablesRelative[3]).isNull() + viewHolder.bind(questionnaireViewItem) + composeTestRule.onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() + + composeTestRule + .onNode( + hasTestTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG) and + hasTextExactly("Coding 3") and + hasAnyAncestor(isPopup()), + ) + .assertIsDisplayed() + .performClick() + composeTestRule + .onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.EditableText, + AnnotatedString("Coding 3"), + ), + ) + composeTestRule + .onNodeWithTag(DROP_DOWN_TEXT_FIELD_LEADING_ICON_TAG, useUnmergedTree = true) + .assertIsDisplayed() } @Test @@ -212,18 +260,26 @@ class DropDownViewHolderFactoryEspressoTest { validationResult = NotValidated, answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, ) - runOnUI { - viewHolder.bind(questionnaireViewItem) - viewHolder.itemView.findViewById(R.id.auto_complete).showDropDown() - } - onView(withId(R.id.auto_complete)).perform(delayMainThread()) - onView(withText("Coding 1")) - .inRoot(isPlatformPopup()) - .check(matches(isDisplayed())) - .perform(click()) - assertThat(viewHolder.itemView.findViewById(R.id.auto_complete).text.toString()) - .isEqualTo("Coding 1") + viewHolder.bind(questionnaireViewItem) + composeTestRule.onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() + composeTestRule + .onNode( + hasTestTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG) and + hasTextExactly("Coding 1") and + hasAnyAncestor(isPopup()), + ) + .assertIsDisplayed() + .performClick() + composeTestRule + .onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.EditableText, + AnnotatedString("Coding 1"), + ), + ) + composeTestRule.waitUntil { answerHolder != null } assertThat((answerHolder!!.single().value as StringType).valueAsString).isEqualTo("Coding 1") } @@ -236,19 +292,12 @@ class DropDownViewHolderFactoryEspressoTest { validationResult = NotValidated, answersChangedCallback = { _, _, _, _ -> }, ) - runOnUI { - viewHolder.bind(questionnaireViewItem) - viewHolder.itemView.findViewById(R.id.auto_complete).showDropDown() - } - onView(withId(R.id.auto_complete)).perform(delayMainThread()) - assertThat( - viewHolder.itemView - .findViewById(R.id.auto_complete) - .adapter - .count, - ) - .isEqualTo(6) // +1 cause of '-' menu item + viewHolder.bind(questionnaireViewItem) + composeTestRule.onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() + composeTestRule + .onAllNodes(hasTestTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG) and hasAnyAncestor(isPopup())) + .assertCountEquals(6) // +1 cause of '-' menu item } @Test @@ -260,20 +309,14 @@ class DropDownViewHolderFactoryEspressoTest { validationResult = NotValidated, answersChangedCallback = { _, _, _, _ -> }, ) - runOnUI { - viewHolder.bind(questionnaireViewItem) - viewHolder.itemView.findViewById(R.id.auto_complete).showDropDown() - } + viewHolder.bind(questionnaireViewItem) + composeTestRule.onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() - onView(withId(R.id.auto_complete)).perform(delayMainThread()) - onView(withId(R.id.auto_complete)).perform(typeText("Coding")) - assertThat( - viewHolder.itemView - .findViewById(R.id.auto_complete) - .adapter - .count, - ) - .isEqualTo(3) + composeTestRule.onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performTextReplacement("Coding") + + composeTestRule + .onAllNodes(hasTestTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG) and hasAnyAncestor(isPopup())) + .assertCountEquals(3) } @Test @@ -291,18 +334,20 @@ class DropDownViewHolderFactoryEspressoTest { answersChangedCallback = { _, _, _, _ -> }, ) - val autoComplete = viewHolder.itemView.findViewById(R.id.auto_complete) - - runOnUI { - viewHolder.bind(questionnaireItem) - autoComplete.showDropDown() - } - - assertThrows(PerformException::class.java) { - onView(withId(R.id.auto_complete)).perform(typeText("new text")) - } - - assertThat(autoComplete.text.toString()).isEqualTo("Coding 1") + viewHolder.bind(questionnaireItem) + composeTestRule.onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() + composeTestRule + .onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG) + .assert(SemanticsMatcher.expectValue(SemanticsProperties.IsEditable, false)) + + composeTestRule + .onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.EditableText, + AnnotatedString("Coding 1"), + ), + ) } @Test @@ -316,22 +361,24 @@ class DropDownViewHolderFactoryEspressoTest { createAnswerOptions(*answerOptions.toTypedArray()), responseValueStringOptions(), validationResult = NotValidated, - answersChangedCallback = { _, _, answers, _ -> selectedAnswers = answers }, + answersChangedCallback = { _, _, answers, _ -> + println(answers) + selectedAnswers = answers + }, ) - val autoComplete = viewHolder.itemView.findViewById(R.id.auto_complete) - - runOnUI { - viewHolder.bind(questionnaireItem) - autoComplete.showDropDown() - } - + viewHolder.bind(questionnaireItem) + composeTestRule.onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() // Test selection flow - onView(withText("Coding 1")) - .inRoot(isPlatformPopup()) - .check(matches(isDisplayed())) - .perform(click()) - + composeTestRule + .onNode( + hasTestTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG) and + hasTextExactly("Coding 1") and + hasAnyAncestor(isPopup()), + ) + .assertIsDisplayed() + .performClick() + composeTestRule.waitForIdle() assertThat(selectedAnswers).hasSize(1) assertThat((selectedAnswers!!.first().value as StringType).valueAsString).isEqualTo("Coding 1") @@ -344,10 +391,9 @@ class DropDownViewHolderFactoryEspressoTest { answersChangedCallback = { _, _, answers, _ -> selectedAnswers = answers }, ) - runOnUI { viewHolder.bind(questionnaireItem) } - - onView(withId(R.id.clear_input_icon)).perform(click()) - + viewHolder.bind(questionnaireItem) + composeTestRule.onNodeWithTag(CLEAR_TEXT_ICON_BUTTON_TAG).performClick() + composeTestRule.waitForIdle() assertThat(selectedAnswers).isEmpty() } @@ -360,20 +406,15 @@ class DropDownViewHolderFactoryEspressoTest { validationResult = NotValidated, answersChangedCallback = { _, _, _, _ -> }, ) - runOnUI { - viewHolder.bind(questionnaireViewItem) - viewHolder.itemView.findViewById(R.id.auto_complete).showDropDown() - } - onView(withId(R.id.auto_complete)).perform(delayMainThread()) - onView(withId(R.id.auto_complete)).perform(typeText("Division")) - assertThat( - viewHolder.itemView - .findViewById(R.id.auto_complete) - .adapter - .count, - ) - .isEqualTo(0) + viewHolder.bind(questionnaireViewItem) + composeTestRule.onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() + + composeTestRule.onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performTextReplacement("Division") + + composeTestRule + .onAllNodes(hasTestTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG) and hasAnyAncestor(isPopup())) + .assertCountEquals(0) } @Test @@ -409,32 +450,26 @@ class DropDownViewHolderFactoryEspressoTest { answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, ) - runOnUI { - viewHolder.bind(questionnaireViewItem) - viewHolder.itemView.findViewById(R.id.auto_complete).showDropDown() - } - - onView(withId(R.id.auto_complete)).perform(delayMainThread()) - onData(`is`(instanceOf(DropDownAnswerOption::class.java))) - .atPosition(2) - .inRoot(isPlatformPopup()) - .perform(click()) - - assertThat(viewHolder.itemView.findViewById(R.id.auto_complete).text.toString()) - .isEqualTo("Reference") + viewHolder.bind(questionnaireViewItem) + composeTestRule.onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() + + composeTestRule + .onAllNodes( + hasTestTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG) and + hasTextExactly("Reference") and + hasAnyAncestor(isPopup()), + )[1] // at position 2 + .performClick() + composeTestRule + .onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.EditableText, + AnnotatedString("Reference"), + ), + ) assertThat((answerHolder!!.single().value as Reference).display).isEqualTo("Reference") - assertThat((answerHolder!!.single().value as Reference).id).isEqualTo("ref_2") - } - - /** Method to run code snippet on UI/main thread */ - private fun runOnUI(action: () -> Unit) { - activityScenarioRule.scenario.onActivity { action() } - } - - /** Method to set content view for test activity */ - private fun setTestLayout(view: View) { - activityScenarioRule.scenario.onActivity { activity -> activity.setContentView(view) } - InstrumentationRegistry.getInstrumentation().waitForIdleSync() + assertThat((answerHolder.single().value as Reference).id).isEqualTo("ref_2") } private fun answerOptions(vararg options: String) = diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/DropDownViewHolderFactoryTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DropDownViewHolderFactoryTest.kt similarity index 67% rename from datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/DropDownViewHolderFactoryTest.kt rename to datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DropDownViewHolderFactoryTest.kt index d3a14ed98a..93d038e315 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/DropDownViewHolderFactoryTest.kt +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DropDownViewHolderFactoryTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2025 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,40 +14,71 @@ * 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.ImageView import android.widget.TextView -import androidx.appcompat.app.AppCompatActivity +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasAnyDescendant +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasTextExactly +import androidx.compose.ui.test.isPopup +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +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.displayString +import com.google.android.fhir.datacapture.extensions.toAnnotatedString +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.QuestionTextConfiguration import com.google.android.fhir.datacapture.views.QuestionnaireViewItem -import com.google.android.material.textfield.TextInputLayout +import com.google.android.fhir.datacapture.views.compose.CLEAR_TEXT_ICON_BUTTON_TAG +import com.google.android.fhir.datacapture.views.compose.DROP_DOWN_ANSWER_MENU_ITEM_TAG +import com.google.android.fhir.datacapture.views.compose.DROP_DOWN_TEXT_FIELD_TAG +import com.google.android.fhir.datacapture.views.compose.ERROR_TEXT_AT_HEADER_TEST_TAG +import com.google.android.fhir.datacapture.views.factories.DropDownViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder import com.google.common.truth.Truth.assertThat import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.Reference +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 DropDownViewHolderFactoryTest { - 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 = DropDownViewHolderFactory.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 = DropDownViewHolderFactory.create(FrameLayout(activity)) + activity.setContentView(viewHolder.itemView) + } + + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + } @Test fun shouldSetQuestionHeader() { @@ -60,6 +91,9 @@ class DropDownViewHolderFactoryTest { ), ) + // Synchronize + composeTestRule.waitForIdle() + assertThat(viewHolder.itemView.findViewById(R.id.question).text.toString()) .isEqualTo("Question?") } @@ -78,15 +112,18 @@ class DropDownViewHolderFactoryTest { answersChangedCallback = { _, _, _, _ -> }, ), ) - val selectedItem = - viewHolder.itemView.findViewById(R.id.auto_complete).adapter.getItem(1) - as DropDownAnswerOption - - assertThat(selectedItem.answerOptionString).isEqualTo("Test Code") + composeTestRule.onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() + composeTestRule + .onNode(isPopup()) + .assert( + hasAnyDescendant( + hasTestTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG) and hasTextExactly("Test Code"), + ), + ) } @Test - fun `should populate dropdown with display for reference value type`() { + fun shouldPopulateDropdownWithDisplayForReferenceValueType() { val answerOption = Questionnaire.QuestionnaireItemAnswerOptionComponent().apply { value = @@ -103,15 +140,18 @@ class DropDownViewHolderFactoryTest { answersChangedCallback = { _, _, _, _ -> }, ), ) - val selectedItem = - viewHolder.itemView.findViewById(R.id.auto_complete).adapter.getItem(1) - as DropDownAnswerOption - - assertThat(selectedItem.answerOptionString).isEqualTo("John Doe") + composeTestRule.onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() + composeTestRule + .onNode(isPopup()) + .assert( + hasAnyDescendant( + hasTestTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG) and hasTextExactly("John Doe"), + ), + ) } @Test - fun `should populate dropdown with type and id for reference value type if missing display`() { + fun shouldPopulateDropdownWithTypeAndIdForReferenceValueTypeIfMissingDisplay() { val answerOption = Questionnaire.QuestionnaireItemAnswerOptionComponent().apply { value = Reference().apply { reference = "Patient/123" } @@ -124,11 +164,14 @@ class DropDownViewHolderFactoryTest { answersChangedCallback = { _, _, _, _ -> }, ), ) - val selectedItem = - viewHolder.itemView.findViewById(R.id.auto_complete).adapter.getItem(1) - as DropDownAnswerOption - - assertThat(selectedItem.answerOptionString).isEqualTo("Patient/123") + composeTestRule.onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() + composeTestRule + .onNode(isPopup()) + .assert( + hasAnyDescendant( + hasTestTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG) and hasTextExactly("Patient/123"), + ), + ) } @Test @@ -145,14 +188,18 @@ class DropDownViewHolderFactoryTest { answersChangedCallback = { _, _, _, _ -> }, ), ) - val selectedItem = - viewHolder.itemView.findViewById(R.id.auto_complete).adapter.getItem(1) - as DropDownAnswerOption - assertThat(selectedItem.answerOptionString).isEqualTo("test-code") + composeTestRule.onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() + composeTestRule + .onNode(isPopup()) + .assert( + hasAnyDescendant( + hasTestTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG) and hasTextExactly("test-code"), + ), + ) } @Test - fun shouldSetAutoTextViewEmptyIfAnswerNull() { + fun shouldSetSelectedTextEmptyIfAnswerNull() { val answerOption = Questionnaire.QuestionnaireItemAnswerOptionComponent().apply { value = @@ -170,14 +217,18 @@ class DropDownViewHolderFactoryTest { ), ) - assertThat( - viewHolder.itemView.findViewById(R.id.auto_complete).text.toString(), + composeTestRule + .onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.EditableText, + AnnotatedString(""), + ), ) - .isEqualTo("") } @Test - fun shouldAutoCompleteTextViewToDisplayIfAnswerNotNull() { + fun shouldSelectedTextToDisplayIfAnswerNotNull() { val answerOption = Questionnaire.QuestionnaireItemAnswerOptionComponent().apply { value = @@ -213,14 +264,20 @@ class DropDownViewHolderFactoryTest { ), ) - assertThat( - viewHolder.itemView.findViewById(R.id.auto_complete).text.toString(), + val context = viewHolder.itemView.context + val answerOptionDisplay = answerOption.value.displayString(context) + composeTestRule + .onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.EditableText, + answerOptionDisplay.toAnnotatedString(), + ), ) - .isEqualTo(answerOption.value.displayString(parent.context)) } @Test - fun shouldAutoCompleteTextViewToDisplayIfAnswerNotNullAndDisplayMatchesMoreThanOneOption() { + fun shouldSelectedTextToDisplayIfAnswerNotNullAndDisplayMatchesMoreThanOneOption() { val answerOption1 = Questionnaire.QuestionnaireItemAnswerOptionComponent().apply { value = @@ -257,10 +314,16 @@ class DropDownViewHolderFactoryTest { ), ) - assertThat( - viewHolder.itemView.findViewById(R.id.auto_complete).text.toString(), + val context = viewHolder.itemView.context + val answerOption2Display = answerOption2.value.displayString(context) + composeTestRule + .onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.EditableText, + answerOption2Display.toAnnotatedString(), + ), ) - .isEqualTo(answerOption2.value.displayString(parent.context)) } @Test @@ -273,9 +336,14 @@ class DropDownViewHolderFactoryTest { answersChangedCallback = { _, _, _, _ -> }, ), ) - - assertThat(viewHolder.itemView.findViewById(R.id.text_input_layout).error) - .isEqualTo("Missing answer for required field.") + composeTestRule + .onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.Error, + "Missing answer for required field.", + ), + ) } @Test @@ -300,13 +368,17 @@ class DropDownViewHolderFactoryTest { answersChangedCallback = { _, _, _, _ -> }, ), ) - - assertThat(viewHolder.itemView.findViewById(R.id.text_input_layout).error) - .isNull() + composeTestRule + .onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.keyNotDefined( + SemanticsProperties.Error, + ), + ) } @Test - fun `hides error textview in the header`() { + fun hidesErrorTextviewInTheHeader() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), @@ -316,8 +388,7 @@ class DropDownViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.error_text_at_header).visibility) - .isEqualTo(View.GONE) + composeTestRule.onNodeWithTag(ERROR_TEXT_AT_HEADER_TEST_TAG).assertDoesNotExist() } @Test @@ -339,9 +410,7 @@ class DropDownViewHolderFactoryTest { answersChangedCallback = { _, _, _, _ -> }, ), ) - - val clearIcon = viewHolder.itemView.findViewById(R.id.clear_input_icon) - assertThat(clearIcon.visibility).isEqualTo(View.GONE) + composeTestRule.onNodeWithTag(CLEAR_TEXT_ICON_BUTTON_TAG).assertDoesNotExist() } @Test @@ -366,8 +435,7 @@ class DropDownViewHolderFactoryTest { ), ) - val clearIcon = viewHolder.itemView.findViewById(R.id.clear_input_icon) - assertThat(clearIcon.visibility).isEqualTo(View.VISIBLE) + composeTestRule.onNodeWithTag(CLEAR_TEXT_ICON_BUTTON_TAG).assertIsDisplayed() } @Test @@ -380,13 +448,11 @@ class DropDownViewHolderFactoryTest { answersChangedCallback = { _, _, _, _ -> }, ), ) - - assertThat(viewHolder.itemView.findViewById(R.id.text_input_layout).isEnabled) - .isFalse() + composeTestRule.onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).assertIsNotEnabled() } @Test - fun `shows asterisk`() { + fun showsAsterisk() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -399,13 +465,15 @@ class DropDownViewHolderFactoryTest { questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = true), ), ) + // Synchronize + composeTestRule.waitForIdle() assertThat(viewHolder.itemView.findViewById(R.id.question).text.toString()) .isEqualTo("Question? *") } @Test - fun `hide asterisk`() { + fun hideAsterisk() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -418,13 +486,14 @@ class DropDownViewHolderFactoryTest { questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = false), ), ) - + // Synchronize + composeTestRule.waitForIdle() assertThat(viewHolder.itemView.findViewById(R.id.question).text.toString()) .isEqualTo("Question?") } @Test - fun `shows required text`() { + fun showsRequiredText() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { required = true }, @@ -435,17 +504,13 @@ class DropDownViewHolderFactoryTest { ), ) - assertThat( - viewHolder.itemView - .findViewById(R.id.text_input_layout) - .helperText - .toString(), - ) - .isEqualTo("Required") + composeTestRule + .onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG) + .assertTextEquals("Required", includeEditableText = false) } @Test - fun `hide required text`() { + fun hideRequiredText() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { required = true }, @@ -456,12 +521,11 @@ class DropDownViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.text_input_layout).helperText) - .isNull() + composeTestRule.onNodeWithText("Required", substring = true).assertDoesNotExist() } @Test - fun `shows optional text`() { + fun showsOptionalText() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), @@ -472,17 +536,13 @@ class DropDownViewHolderFactoryTest { ), ) - assertThat( - viewHolder.itemView - .findViewById(R.id.text_input_layout) - .helperText - .toString(), - ) - .isEqualTo("Optional") + composeTestRule + .onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG) + .assertTextEquals("Optional", includeEditableText = false) } @Test - fun `hide optional text`() { + fun hideOptionalText() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), @@ -493,7 +553,6 @@ class DropDownViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.text_input_layout).helperText) - .isNull() + composeTestRule.onNodeWithText("Optional", substring = true).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/test/java/com/google/android/fhir/datacapture/views/factories/SliderViewHolderFactoryTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/SliderViewHolderFactoryTest.kt similarity index 65% rename from datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/SliderViewHolderFactoryTest.kt rename to datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/SliderViewHolderFactoryTest.kt index 33195746bf..217f529eca 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/SliderViewHolderFactoryTest.kt +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/SliderViewHolderFactoryTest.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,44 +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.FrameLayout import android.widget.TextView -import androidx.appcompat.app.AppCompatActivity +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertRangeInfoEquals +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performSemanticsAction +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.EXTENSION_DISPLAY_CATEGORY_INSTRUCTIONS -import com.google.android.fhir.datacapture.extensions.EXTENSION_DISPLAY_CATEGORY_SYSTEM -import com.google.android.fhir.datacapture.extensions.EXTENSION_DISPLAY_CATEGORY_URL import com.google.android.fhir.datacapture.extensions.EXTENSION_SLIDER_STEP_VALUE_URL +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.QuestionTextConfiguration import com.google.android.fhir.datacapture.views.QuestionnaireViewItem -import com.google.android.material.slider.Slider +import com.google.android.fhir.datacapture.views.compose.ERROR_TEXT_AT_HEADER_TEST_TAG +import com.google.android.fhir.datacapture.views.compose.ERROR_TEXT_TAG +import com.google.android.fhir.datacapture.views.compose.SLIDER_TAG +import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder +import com.google.android.fhir.datacapture.views.factories.SliderViewHolderFactory import com.google.common.truth.Truth.assertThat -import kotlin.test.assertFailsWith -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.IntegerType import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.junit.Assert.assertThrows +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 SliderViewHolderFactoryTest { - 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 = SliderViewHolderFactory.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 = SliderViewHolderFactory.create(FrameLayout(activity)) + activity.setContentView(viewHolder.itemView) + } + + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + } @Test fun shouldSetQuestionHeader() { @@ -64,6 +86,9 @@ class SliderViewHolderFactoryTest { ), ) + // Synchronize + composeTestRule.waitForIdle() + assertThat(viewHolder.itemView.findViewById(R.id.question).text.toString()) .isEqualTo("Question?") } @@ -84,12 +109,13 @@ class SliderViewHolderFactoryTest { answersChangedCallback = { _, _, _, _ -> }, ), ) - - assertThat(viewHolder.itemView.findViewById(R.id.slider).value).isEqualTo(10) + composeTestRule + .onNodeWithTag(SLIDER_TAG) + .assertRangeInfoEquals(ProgressBarRangeInfo(current = 10f, range = 0f..100f, steps = 99)) } @Test - fun `step size should come from the sliderStepValue extension`() { + fun stepSizeShouldComeFromTheSliderStepValueExtension() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -102,11 +128,17 @@ class SliderViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.slider).stepSize).isEqualTo(10) + val sliderStepsFromStepSize10: Int = 100.div(10) - 1 + + composeTestRule + .onNodeWithTag(SLIDER_TAG) + .assertRangeInfoEquals( + ProgressBarRangeInfo(current = 0f, range = 0f..100f, steps = sliderStepsFromStepSize10), + ) } @Test - fun `step size should be 1 if the sliderStepValue extension is not present`() { + fun stepSizeShouldBe1IfTheSliderStepValueExtensionIsNotPresent() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { linkId = "slider-step-value" }, @@ -116,11 +148,17 @@ class SliderViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.slider).stepSize).isEqualTo(1) + val sliderStepsWithStepSize1: Int = 100 - 1 + + composeTestRule + .onNodeWithTag(SLIDER_TAG) + .assertRangeInfoEquals( + ProgressBarRangeInfo(current = 0f, range = 0f..100f, steps = sliderStepsWithStepSize1), + ) } @Test - fun `slider valueTo should come from the maxValue extension`() { + fun sliderValueToShouldComeFromTheMaxValueExtension() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -135,11 +173,13 @@ class SliderViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.slider).valueTo).isEqualTo(200) + composeTestRule + .onNodeWithTag(SLIDER_TAG) + .assertRangeInfoEquals(ProgressBarRangeInfo(current = 0f, range = 0f..200f, steps = 199)) } @Test - fun `slider valueTo should be set to default value if maxValue extension is not present`() { + fun sliderValueToShouldBeSetToDefaultValueIfMaxValueExtensionIsNotPresent() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), @@ -149,11 +189,13 @@ class SliderViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.slider).valueTo).isEqualTo(100.0F) + composeTestRule + .onNodeWithTag(SLIDER_TAG) + .assertRangeInfoEquals(ProgressBarRangeInfo(current = 0f, range = 0f..100f, steps = 99)) } @Test - fun `slider valueFrom should come from the maxValue extension`() { + fun sliderValueFromShouldComeFromTheMinValueExtension() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -168,11 +210,13 @@ class SliderViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.slider).valueFrom).isEqualTo(50) + composeTestRule + .onNodeWithTag(SLIDER_TAG) + .assertRangeInfoEquals(ProgressBarRangeInfo(current = 50f, range = 50f..100f, steps = 49)) } @Test - fun `slider valueFrom should be set to default value if minValue extension is not present`() { + fun sliderValueFromShouldBeSetToDefaultValueIfMinValueExtensionIsNotPresent() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), @@ -182,30 +226,38 @@ class SliderViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.slider).valueFrom).isEqualTo(0.0F) + composeTestRule + .onNodeWithTag(SLIDER_TAG) + .assertRangeInfoEquals(ProgressBarRangeInfo(current = 0f, range = 0f..100f, steps = 99)) } @Test - fun `throws exception if minValue is greater than maxvalue`() { - assertFailsWith { - viewHolder.bind( - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent().apply { - addExtension().apply { - url = "http://hl7.org/fhir/StructureDefinition/minValue" - setValue(IntegerType("100")) - } - addExtension().apply { - url = "http://hl7.org/fhir/StructureDefinition/maxValue" - setValue(IntegerType("50")) - } - }, - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ) - } + fun throwsExceptionIfMinValueIsGreaterThanMaxvalue() { + assertThrows( + IllegalStateException::class.java, + { + viewHolder.bind( + QuestionnaireViewItem( + Questionnaire.QuestionnaireItemComponent().apply { + addExtension().apply { + url = "http://hl7.org/fhir/StructureDefinition/minValue" + setValue(IntegerType("100")) + } + addExtension().apply { + url = "http://hl7.org/fhir/StructureDefinition/maxValue" + setValue(IntegerType("50")) + } + }, + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + // Wait for synchronization + composeTestRule.waitForIdle() + }, + ) } @Test @@ -220,13 +272,18 @@ class SliderViewHolderFactoryTest { ) viewHolder.bind(questionnaireViewItem) - viewHolder.itemView.findViewById(R.id.slider).value = 10.0F - assertThat(answerHolder!!.single().valueIntegerType.value).isEqualTo(10) + composeTestRule.onNodeWithTag(SLIDER_TAG).performSemanticsAction(SemanticsActions.SetProgress) { + it.invoke(20f) + } + // Synchronize + composeTestRule.waitForIdle() + + assertThat(answerHolder!!.single().valueIntegerType.value).isEqualTo(20) } @Test - fun shouldSetSliderValueToDefault() { + fun shouldSetSliderValueToDefaultWhenQuestionnaireResponseHasMultipleAnswers() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), @@ -246,8 +303,9 @@ class SliderViewHolderFactoryTest { answersChangedCallback = { _, _, _, _ -> }, ), ) - - assertThat(viewHolder.itemView.findViewById(R.id.slider).value).isEqualTo(0.0F) + composeTestRule + .onNodeWithTag(SLIDER_TAG) + .assertRangeInfoEquals(ProgressBarRangeInfo(0.0f, 0.0f..100f, steps = 99)) } @Test @@ -276,7 +334,7 @@ class SliderViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.error).text.toString()).isEqualTo("") + composeTestRule.onNodeWithTag(ERROR_TEXT_TAG).assertIsNotDisplayed().assertDoesNotExist() } @Test @@ -305,12 +363,11 @@ class SliderViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.error).text.toString()) - .isEqualTo("Minimum value allowed is:50") + composeTestRule.onNodeWithTag(ERROR_TEXT_TAG).assertTextEquals("Minimum value allowed is:50") } @Test - fun `hides error textview in the header`() { + fun hidesErrorTextviewInTheHeader() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), @@ -319,9 +376,10 @@ class SliderViewHolderFactoryTest { 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() } @Test @@ -334,12 +392,11 @@ class SliderViewHolderFactoryTest { answersChangedCallback = { _, _, _, _ -> }, ), ) - - assertThat(viewHolder.itemView.findViewById(R.id.slider).isEnabled).isFalse() + composeTestRule.onNodeWithTag(SLIDER_TAG).assertIsNotEnabled() } @Test - fun `bind multiple times with different QuestionnaireItemViewItem should show proper slider value`() { + fun bindMultipleTimesWithDifferentQuestionnaireItemViewItemShouldShowProperSliderValue() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), @@ -355,7 +412,9 @@ class SliderViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.slider).value).isEqualTo(10) + composeTestRule + .onNodeWithTag(SLIDER_TAG) + .assertRangeInfoEquals(ProgressBarRangeInfo(10f, 0f..100f, steps = 99)) viewHolder.bind( QuestionnaireViewItem( @@ -372,7 +431,9 @@ class SliderViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.slider).value).isEqualTo(12) + composeTestRule + .onNodeWithTag(SLIDER_TAG) + .assertRangeInfoEquals(ProgressBarRangeInfo(12f, 0f..100f, steps = 99)) viewHolder.bind( QuestionnaireViewItem( @@ -388,11 +449,13 @@ class SliderViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.slider).value).isEqualTo(50) + composeTestRule + .onNodeWithTag(SLIDER_TAG) + .assertRangeInfoEquals(ProgressBarRangeInfo(50f, 50f..100f, steps = 49)) } @Test - fun `hide asterisk`() { + fun hidesAsterisk() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -406,12 +469,15 @@ class SliderViewHolderFactoryTest { ), ) + // Synchronize + composeTestRule.waitForIdle() + assertThat(viewHolder.itemView.findViewById(R.id.question).text.toString()) .isEqualTo("Question") } @Test - fun `show required text`() { + fun showsRequiredText() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { required = true }, @@ -422,14 +488,11 @@ class SliderViewHolderFactoryTest { ), ) - assertThat( - viewHolder.itemView.findViewById(R.id.required_optional_text).text.toString(), - ) - .isEqualTo("Required") + composeTestRule.onNodeWithText("Required").assertIsDisplayed() } @Test - fun `hide required text`() { + fun hidesRequiredtext() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { required = true }, @@ -440,16 +503,11 @@ class SliderViewHolderFactoryTest { ), ) - assertThat( - viewHolder.itemView.findViewById(R.id.required_optional_text).text.toString(), - ) - .isEmpty() - assertThat(viewHolder.itemView.findViewById(R.id.required_optional_text).visibility) - .isEqualTo(View.GONE) + composeTestRule.onNodeWithText("Required").assertIsNotDisplayed().assertDoesNotExist() } @Test - fun `show optional text`() { + fun showsOptionalText() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { text = "Question" }, @@ -460,14 +518,11 @@ class SliderViewHolderFactoryTest { ), ) - assertThat( - viewHolder.itemView.findViewById(R.id.required_optional_text).text.toString(), - ) - .isEqualTo("Optional") + composeTestRule.onNodeWithText("Optional").assertIsDisplayed() } @Test - fun `hide optional text`() { + fun hidesOptionalText() { viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { text = "Question" }, @@ -478,27 +533,6 @@ class SliderViewHolderFactoryTest { ), ) - assertThat( - viewHolder.itemView.findViewById(R.id.required_optional_text).text.toString(), - ) - .isEmpty() - assertThat(viewHolder.itemView.findViewById(R.id.required_optional_text).visibility) - .isEqualTo(View.GONE) + composeTestRule.onNodeWithText("Optional").assertIsNotDisplayed().assertDoesNotExist() } - - private val displayCategoryExtensionWithInstructionsCode = - Extension().apply { - url = EXTENSION_DISPLAY_CATEGORY_URL - setValue( - CodeableConcept().apply { - coding = - listOf( - Coding().apply { - code = EXTENSION_DISPLAY_CATEGORY_INSTRUCTIONS - system = EXTENSION_DISPLAY_CATEGORY_SYSTEM - }, - ) - }, - ) - } } diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/TimePickerViewHolderFactoryTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/TimePickerViewHolderFactoryTest.kt similarity index 55% rename from datacapture/src/test/java/com/google/android/fhir/datacapture/views/TimePickerViewHolderFactoryTest.kt rename to datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/TimePickerViewHolderFactoryTest.kt index 90e4d5bc3f..d4a0a17a0a 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/TimePickerViewHolderFactoryTest.kt +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/TimePickerViewHolderFactoryTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 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,39 +14,55 @@ * limitations under the License. */ -package com.google.android.fhir.datacapture.views +package com.google.android.fhir.datacapture.test.views +import android.text.format.DateFormat import android.widget.FrameLayout import android.widget.TextView -import androidx.appcompat.app.AppCompatActivity +import androidx.compose.ui.test.assertTextEquals +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.TIME_PICKER_INPUT_FIELD import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder import com.google.android.fhir.datacapture.views.factories.TimePickerViewHolderFactory import com.google.common.truth.Truth.assertThat import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.TimeType +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 -import org.robolectric.shadows.ShadowSettings -@RunWith(RobolectricTestRunner::class) +@RunWith(AndroidJUnit4::class) class TimePickerViewHolderFactoryTest { - private val context = - Robolectric.buildActivity(AppCompatActivity::class.java).create().get().apply { - setTheme(com.google.android.material.R.style.Theme_Material3_DayNight) - } - private val parent = FrameLayout(context) - private val viewHolder = TimePickerViewHolderFactory.create(parent) + @get:Rule + val activityScenarioRule: ActivityScenarioRule = + ActivityScenarioRule(TestActivity::class.java) + + @get:Rule val composeTestRule = createEmptyComposeRule() + + private lateinit var viewHolder: QuestionnaireItemViewHolder + private lateinit var parent: FrameLayout - private val QuestionnaireItemViewHolder.timeInputView: TextView - get() { - return itemView.findViewById(R.id.text_input_edit_text) + @Before + fun setUp() { + activityScenarioRule.scenario.onActivity { activity -> + parent = FrameLayout(activity) + viewHolder = TimePickerViewHolderFactory.create(parent) + activity.setContentView(viewHolder.itemView) } + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + } + @Test fun shouldSetQuestionHeader() { viewHolder.bind( @@ -57,6 +73,8 @@ class TimePickerViewHolderFactoryTest { answersChangedCallback = { _, _, _, _ -> }, ), ) + // Synchronize + composeTestRule.waitForIdle() assertThat(viewHolder.itemView.findViewById(R.id.question).text.toString()) .isEqualTo("Question?") @@ -73,60 +91,56 @@ class TimePickerViewHolderFactoryTest { ), ) - assertThat(viewHolder.timeInputView.text.toString()).isEqualTo("") + composeTestRule + .onNodeWithTag(TIME_PICKER_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals("") } @Test - fun `should show AM time when set time format is 12 hrs`() { - ShadowSettings.set24HourTimeFormat(false) - viewHolder.bind( - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent().apply { text = "Question?" }, - QuestionnaireResponse.QuestionnaireResponseItemComponent() - .addAnswer( - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent() - .setValue(TimeType("10:10")), - ), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ) - assertThat(viewHolder.timeInputView.text.toString()).isEqualTo("10:10 AM") - } + fun shouldDisplayAMTimeInCorrectFormat_dependingOnSystemSettings() { + val context = viewHolder.itemView.context + val is24Hour = DateFormat.is24HourFormat(context) - @Test - fun `should show PM time when set time format is 12 hrs`() { - ShadowSettings.set24HourTimeFormat(false) viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { text = "Question?" }, QuestionnaireResponse.QuestionnaireResponseItemComponent() .addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent() - .setValue(TimeType("22:10:10")), + .setValue(TimeType("10:10:00")), ), validationResult = NotValidated, answersChangedCallback = { _, _, _, _ -> }, ), ) - assertThat(viewHolder.timeInputView.text.toString()).isEqualTo("10:10 PM") + + val expectedTime = if (is24Hour) "10:10" else "10:10 AM" + composeTestRule + .onNodeWithTag(TIME_PICKER_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals(expectedTime) } @Test - fun `should show time when set time format is 24 hrs`() { - ShadowSettings.set24HourTimeFormat(true) + fun shouldDisplayPMTimeInCorrectFormat_dependingOnSystemSettings() { + val context = viewHolder.itemView.context + val is24Hour = DateFormat.is24HourFormat(context) + viewHolder.bind( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent().apply { text = "Question?" }, QuestionnaireResponse.QuestionnaireResponseItemComponent() .addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent() - .setValue(TimeType("22:10")), + .setValue(TimeType("22:10:00")), ), validationResult = NotValidated, answersChangedCallback = { _, _, _, _ -> }, ), ) - assertThat(viewHolder.timeInputView.text.toString()).isEqualTo("22:10") + + val expectedTime = if (is24Hour) "22:10" else "10:10 PM" + composeTestRule + .onNodeWithTag(TIME_PICKER_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals(expectedTime) } } diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/compose/DropDownItemTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/compose/DropDownItemTest.kt new file mode 100644 index 0000000000..264ba02241 --- /dev/null +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/compose/DropDownItemTest.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.DropDownItem +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 DropDownItemTest { + + @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 { + DropDownItem( + 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/MoreLocalDates.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreLocalDates.kt index 399b420dc9..ef069bf4b6 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreLocalDates.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreLocalDates.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 Google LLC + * Copyright 2022-2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,18 +17,25 @@ package com.google.android.fhir.datacapture.extensions import android.icu.text.DateFormat -import com.google.android.fhir.datacapture.views.factories.length -import com.google.android.fhir.datacapture.views.factories.localDate +import com.google.android.fhir.datacapture.views.factories.ZONE_ID_UTC import java.lang.Character.isLetter import java.text.ParseException +import java.time.Instant import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime import java.time.ZoneId import java.time.chrono.IsoChronology import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatterBuilder +import java.time.format.DateTimeParseException import java.time.format.FormatStyle import java.util.Date import java.util.Locale +import kotlin.math.abs +import kotlin.math.log10 +import org.hl7.fhir.r4.model.DateTimeType +import org.hl7.fhir.r4.model.DateType /** * Returns the first character that is not a letter in the given date pattern string (e.g. "/" for @@ -104,6 +111,16 @@ internal fun parseDate(text: String, datePattern: String): LocalDate { return localDate } +internal fun parseLocalDateOrNull(dateToDisplay: String, pattern: String): LocalDate? { + return try { + parseDate(dateToDisplay, pattern) + } catch (_: ParseException) { + null + } catch (_: DateTimeParseException) { + null + } +} + /** * Returns the local date string using the provided date pattern, or the default date pattern for * the system locale if no date pattern is provided. @@ -129,3 +146,57 @@ internal fun getLocalizedDatePattern(): String { Locale.getDefault(), ) } + +internal val DateType.localDate + get() = + if (!this.hasValue()) { + null + } else { + LocalDate.of( + year, + month + 1, + day, + ) + } + +internal val LocalDate.dateType + get() = DateType(year, monthValue - 1, dayOfMonth) + +internal val Date.localDate + get() = LocalDate.of(year + 1900, month + 1, date) + +fun Long.toLocalDate(): LocalDate = Instant.ofEpochMilli(this).atZone(ZONE_ID_UTC).toLocalDate() + +// Count the number of digits in an Integer +internal fun Int.length() = + when (this) { + 0 -> 1 + else -> log10(abs(toDouble())).toInt() + 1 + } + +internal val DateTimeType.localDate + get() = + LocalDate.of( + year, + month + 1, + day, + ) + +internal val DateTimeType.localTime + get() = + LocalTime.of( + hour, + minute, + second, + ) + +internal val DateTimeType.localDateTime + get() = + LocalDateTime.of( + year, + month + 1, + day, + hour, + minute, + second, + ) 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/extensions/MoreTypes.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt index 6c22b144cb..a4e816753d 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt @@ -20,8 +20,6 @@ import android.content.Context import android.text.Spanned import androidx.compose.ui.text.AnnotatedString import com.google.android.fhir.datacapture.R -import com.google.android.fhir.datacapture.views.factories.localDate -import com.google.android.fhir.datacapture.views.factories.localTime import com.google.android.fhir.getLocalizedText import org.hl7.fhir.r4.model.Attachment import org.hl7.fhir.r4.model.BooleanType diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/DatePickerItem.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/DatePickerItem.kt new file mode 100644 index 0000000000..4d643e3dad --- /dev/null +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/DatePickerItem.kt @@ -0,0 +1,200 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.datacapture.views.compose + +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SelectableDates +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +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.focus.FocusDirection +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.error +import androidx.compose.ui.semantics.semantics +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.format +import com.google.android.fhir.datacapture.extensions.toLocalDate +import java.time.LocalDate + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun DatePickerItem( + modifier: Modifier = Modifier, + initialSelectedDateMillis: Long?, + dateInput: DateInput, + labelText: String, + helperText: String?, + isError: Boolean, + enabled: Boolean, + dateInputFormat: DateInputFormat, + selectableDates: SelectableDates?, + parseStringToLocalDate: (String, DateFormatPattern) -> LocalDate?, + onDateInputEntry: (DateInput) -> Unit, +) { + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + var dateInputState by remember(dateInput) { mutableStateOf(dateInput) } + val dateInputDisplay by remember(dateInputState) { derivedStateOf { dateInputState.display } } + + var showDatePickerModal by remember { mutableStateOf(false) } + + LaunchedEffect(dateInputState) { + if (dateInputState != dateInput) { + onDateInputEntry(dateInputState) + } + } + + OutlinedTextField( + value = dateInputDisplay, + onValueChange = { + if ( + it.length <= dateInputFormat.patternWithoutDelimiters.length && + it.all { char -> char.isDigit() } + ) { + val trimmedText = it.trim() + val localDate = + if ( + trimmedText.isNotBlank() && + trimmedText.length == dateInputFormat.patternWithoutDelimiters.length + ) { + parseStringToLocalDate(trimmedText, dateInputFormat.patternWithoutDelimiters) + } else { + null + } + dateInputState = DateInput(it, localDate) + } + }, + singleLine = true, + label = { Text(labelText) }, + modifier = + modifier + .testTag(DATE_TEXT_INPUT_FIELD) + .onFocusChanged { + if (!it.isFocused) { + keyboardController?.hide() + } + } + .semantics { if (isError) error(helperText ?: "") }, + supportingText = { helperText?.let { Text(it) } }, + isError = isError, + trailingIcon = { + IconButton(onClick = { showDatePickerModal = true }, enabled = enabled) { + Icon( + painterResource(R.drawable.gm_calendar_today_24), + contentDescription = stringResource(R.string.select_date), + ) + } + }, + enabled = enabled, + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), + keyboardActions = + KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Down) }, + ), + visualTransformation = DateVisualTransformation(dateInputFormat), + ) + + if (selectableDates != null && showDatePickerModal) { + DatePickerModal( + initialSelectedDateMillis, + selectableDates, + onDateSelected = { dateMillis -> + dateMillis?.toLocalDate()?.let { + dateInputState = + DateInput( + display = it.format(dateInputFormat.patternWithoutDelimiters), + value = it, + ) + } + }, + ) { + showDatePickerModal = false + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun DatePickerModal( + initialSelectedDateMillis: Long?, + selectableDates: SelectableDates, + onDateSelected: (Long?) -> Unit, + onDismiss: () -> Unit, +) { + val datePickerState = + rememberDatePickerState(initialSelectedDateMillis, selectableDates = selectableDates) + val datePickerSelectedDateMillis = + remember(initialSelectedDateMillis) { initialSelectedDateMillis } + val confirmEnabled by remember { derivedStateOf { datePickerState.selectedDateMillis != null } } + + LaunchedEffect(datePickerSelectedDateMillis) { + if (datePickerSelectedDateMillis != datePickerState.selectedDateMillis) { + datePickerState.selectedDateMillis = datePickerSelectedDateMillis + } + } + + DatePickerDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton( + onClick = { + onDateSelected(datePickerState.selectedDateMillis) + onDismiss() + }, + enabled = confirmEnabled, + ) { + Text("OK") + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) { + DatePicker(state = datePickerState) + } +} + +typealias DateFormatPattern = String + +data class DateInput(val display: String, val value: LocalDate?) + +const val DATE_TEXT_INPUT_FIELD = "date_picker_text_field" diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/DateVisualTransformation.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/DateVisualTransformation.kt new file mode 100644 index 0000000000..c498121c9c --- /dev/null +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/DateVisualTransformation.kt @@ -0,0 +1,78 @@ +/* + * 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.ui.text.AnnotatedString +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation + +class DateVisualTransformation( + val dateInputFormat: DateInputFormat, +) : VisualTransformation { + + private val firstDelimiterOffset: Int = + dateInputFormat.patternWithDelimiters.indexOf(dateInputFormat.delimiter) + private val secondDelimiterOffset: Int = + dateInputFormat.patternWithDelimiters.lastIndexOf(dateInputFormat.delimiter) + private val dateFormatLength: Int = dateInputFormat.patternWithoutDelimiters.length + + private val dateOffsetTranslator = + object : OffsetMapping { + + override fun originalToTransformed(offset: Int): Int { + return when { + firstDelimiterOffset == -1 -> offset + offset < firstDelimiterOffset -> offset + offset < secondDelimiterOffset -> offset + 1 + offset <= dateFormatLength -> offset + 2 + else -> dateFormatLength + 2 // 10 + } + } + + override fun transformedToOriginal(offset: Int): Int { + return when { + firstDelimiterOffset == -1 -> offset + offset <= firstDelimiterOffset - 1 -> offset + offset <= secondDelimiterOffset - 1 -> offset - 1 + offset <= dateFormatLength + 1 -> offset - 2 + else -> dateFormatLength // 8 + } + } + } + + override fun filter(text: AnnotatedString): TransformedText { + val trimmedText = + if (text.text.length > dateFormatLength) { + text.text.substring(0 until dateFormatLength) + } else { + text.text + } + var transformedText = "" + trimmedText.forEachIndexed { index, char -> + transformedText += char + if (index + 1 == firstDelimiterOffset || index + 2 == secondDelimiterOffset) { + transformedText += dateInputFormat.delimiter + } + } + return TransformedText(AnnotatedString(transformedText), dateOffsetTranslator) + } +} + +data class DateInputFormat(val patternWithDelimiters: String, val delimiter: Char) { + val patternWithoutDelimiters: String = patternWithDelimiters.replace(delimiter.toString(), "") +} diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/DropDownItem.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/DropDownItem.kt new file mode 100644 index 0000000000..8cf29fb6e5 --- /dev/null +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/DropDownItem.kt @@ -0,0 +1,246 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.datacapture.views.compose + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.error +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.core.graphics.drawable.toBitmap +import com.google.android.fhir.datacapture.R +import com.google.android.fhir.datacapture.views.factories.DropDownAnswerOption + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun DropDownItem( + modifier: Modifier, + enabled: Boolean, + labelText: AnnotatedString? = null, + supportingText: String? = null, + isError: Boolean = false, + 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.fillMaxWidth() + .testTag(DROP_DOWN_TEXT_FIELD_TAG) + .semantics { if (isError) error(supportingText ?: "") } + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable, enabled), + readOnly = true, + enabled = enabled, + minLines = 1, + isError = isError, + label = { labelText?.let { Text(it) } }, + supportingText = { supportingText?.let { Text(it) } }, + leadingIcon = + selectedDropDownAnswerOption?.answerOptionImage?.let { + { + Icon( + it.toBitmap().asImageBitmap(), + contentDescription = selectedOptionDisplay, + modifier = Modifier.testTag(DROP_DOWN_TEXT_FIELD_LEADING_ICON_TAG), + ) + } + }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + options.forEach { option -> + DropDownAnswerMenuItem(enabled, option) { + selectedDropDownAnswerOption = option + expanded = false + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun DropDownAnswerMenuItem( + enabled: Boolean, + answerOption: DropDownAnswerOption, + onSelected: () -> Unit, +) { + DropdownMenuItem( + modifier = Modifier.testTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG), + text = { + Text(answerOption.answerOptionAnnotatedString(), style = MaterialTheme.typography.bodyLarge) + }, + leadingIcon = + answerOption.answerOptionImage?.let { + { + Icon( + it.toBitmap().asImageBitmap(), + contentDescription = answerOption.answerOptionString, + ) + } + }, + enabled = enabled, + onClick = { onSelected() }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun AutoCompleteDropDownItem( + modifier: Modifier, + enabled: Boolean, + labelText: AnnotatedString? = null, + supportingText: String? = null, + isError: Boolean = false, + showClearIcon: Boolean = false, + readOnly: Boolean = showClearIcon, + selectedOption: DropDownAnswerOption? = null, + options: List, + onDropDownAnswerOptionSelected: (DropDownAnswerOption?) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + var selectedDropDownAnswerOption by + remember(selectedOption, options) { mutableStateOf(selectedOption) } + var selectedOptionDisplay by + remember(selectedDropDownAnswerOption) { + val stringValue = selectedDropDownAnswerOption?.answerOptionString ?: "" + mutableStateOf(TextFieldValue(stringValue, selection = TextRange(stringValue.length))) + } + val filteredOptions = + remember(options, selectedOptionDisplay) { + options.filter { it.answerOptionString.contains(selectedOptionDisplay.text, true) } + } + + LaunchedEffect(selectedDropDownAnswerOption) { + onDropDownAnswerOptionSelected(selectedDropDownAnswerOption) + } + + ExposedDropdownMenuBox( + modifier = modifier, + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + OutlinedTextField( + value = selectedOptionDisplay, + onValueChange = { + selectedOptionDisplay = it + if (!expanded) expanded = true + }, + modifier = + Modifier.fillMaxWidth() + .testTag(DROP_DOWN_TEXT_FIELD_TAG) + .semantics { if (isError) error(supportingText ?: "") } + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable, enabled), + readOnly = readOnly, + enabled = enabled, + minLines = 1, + isError = isError, + label = { labelText?.let { Text(it) } }, + supportingText = { supportingText?.let { Text(it) } }, + leadingIcon = + selectedDropDownAnswerOption?.answerOptionImage?.let { + { + Icon( + it.toBitmap().asImageBitmap(), + contentDescription = selectedDropDownAnswerOption!!.answerOptionString, + modifier = Modifier.testTag(DROP_DOWN_TEXT_FIELD_LEADING_ICON_TAG), + ) + } + }, + trailingIcon = { + Row(verticalAlignment = Alignment.CenterVertically) { + if (showClearIcon) { + IconButton( + onClick = { selectedDropDownAnswerOption = null }, + modifier = Modifier.testTag(CLEAR_TEXT_ICON_BUTTON_TAG), + ) { + Icon(painterResource(R.drawable.ic_clear), contentDescription = "clear") + } + } + ExposedDropdownMenuDefaults.TrailingIcon( + expanded = expanded, + modifier = + Modifier.menuAnchor( + ExposedDropdownMenuAnchorType.SecondaryEditable, + enabled, + ), + ) + } + }, + ) + + if (filteredOptions.isNotEmpty()) { + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + filteredOptions.forEach { option -> + DropDownAnswerMenuItem(enabled, option) { + selectedDropDownAnswerOption = option + expanded = false + } + } + } + } + } +} + +const val CLEAR_TEXT_ICON_BUTTON_TAG = "clear_field_text" +const val DROP_DOWN_TEXT_FIELD_TAG = "drop_down_text_field" +const val DROP_DOWN_TEXT_FIELD_LEADING_ICON_TAG = "drop_down_text_field_leading_icon" +const val DROP_DOWN_ANSWER_MENU_ITEM_TAG = "drop_down_answer_list_menu_item" 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/ErrorText.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/ErrorText.kt new file mode 100644 index 0000000000..2bc56bc587 --- /dev/null +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/ErrorText.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.datacapture.views.compose + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.dimensionResource +import com.google.android.fhir.datacapture.R + +@Composable +internal fun ErrorText(validationMessage: String) { + Text( + text = validationMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = + Modifier.padding(start = dimensionResource(R.dimen.error_text_margin_horizontal)) + .testTag(ERROR_TEXT_TAG), + ) +} + +const val ERROR_TEXT_TAG = "error_text" 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..3e0d57a363 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/Header.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/Header.kt @@ -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 @@ -146,7 +147,11 @@ internal fun Header( // Required/Optional Text if (showRequiredOrOptionalText && !requiredOptionalText.isNullOrBlank()) { - Text(text = requiredOptionalText, style = MaterialTheme.typography.bodyMedium) + Text( + text = requiredOptionalText, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.testTag(REQUIRED_OPTIONAL_HEADER_TEXT_TAG), + ) } // Validation Error @@ -254,7 +259,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 +276,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 +300,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 }, ) } @@ -296,6 +320,7 @@ internal fun Help( } const val ERROR_TEXT_AT_HEADER_TEST_TAG = "error_text_at_header" +const val REQUIRED_OPTIONAL_HEADER_TEXT_TAG = "required_optional_header_text" const val HELP_BUTTON_TAG = "helpButton" const val HELP_CARD_TAG = "helpCardView" const val HEADER_TAG = "headerView" diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/MultiAutoCompleteTextItem.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/MultiAutoCompleteTextItem.kt new file mode 100644 index 0000000000..69a96c165b --- /dev/null +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/MultiAutoCompleteTextItem.kt @@ -0,0 +1,191 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.datacapture.views.compose + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.Icon +import androidx.compose.material3.InputChip +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.error +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import com.google.android.fhir.datacapture.R +import com.google.android.fhir.datacapture.views.factories.DropDownAnswerOption + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun MultiAutoCompleteTextItem( + modifier: Modifier, + enabled: Boolean, + labelText: AnnotatedString? = null, + supportingText: String? = null, + isError: Boolean = false, + selectedOptions: List = emptyList(), + options: List, + onNewOptionSelected: (DropDownAnswerOption) -> Unit, + onOptionDeselected: (DropDownAnswerOption) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + var autoCompleteText by remember(options) { mutableStateOf(TextFieldValue("")) } + val filteredOptions = + remember(options, autoCompleteText) { + options.filter { it.answerOptionString.contains(autoCompleteText.text, true) } + } + + // Track the height of the chip container to add padding to text field + var chipContainerHeight by remember { mutableIntStateOf(0) } + val density = LocalDensity.current + val chipMargin = dimensionResource(R.dimen.auto_complete_chip_margin) + val chipMarginBottom = dimensionResource(R.dimen.auto_complete_chip_margin_bottom) + + val interactionSource = remember { MutableInteractionSource() } + val colors = OutlinedTextFieldDefaults.colors() + val contentPadding = + remember(chipContainerHeight, selectedOptions.size) { + PaddingValues( + start = 16.dp, + top = + if (selectedOptions.isNotEmpty()) { + with(density) { chipContainerHeight.toDp() } + 16.dp + } else { + 16.dp + }, + end = 16.dp, + bottom = 16.dp, + ) + } + + ExposedDropdownMenuBox( + modifier = modifier, + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + Box { + // Text field fills the parent and has content padding for chips + BasicTextField( + value = autoCompleteText, + onValueChange = { + autoCompleteText = it + if (!expanded && autoCompleteText.text.isNotBlank()) expanded = true + }, + modifier = + Modifier.fillMaxWidth() + .testTag(MULTI_AUTO_COMPLETE_TEXT_FIELD_TAG) + .semantics { if (isError) error(supportingText ?: "") } + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable, enabled), + enabled = enabled, + textStyle = TextStyle.Default, + cursorBrush = SolidColor(colors.cursorColor), + interactionSource = interactionSource, + decorationBox = { innerTextField -> + OutlinedTextFieldDefaults.DecorationBox( + value = autoCompleteText.text, + innerTextField = innerTextField, + enabled = enabled, + singleLine = false, + visualTransformation = VisualTransformation.None, + interactionSource = interactionSource, + isError = isError, + label = labelText?.let { { Text(it) } }, + supportingText = supportingText?.let { { Text(it) } }, + colors = colors, + contentPadding = contentPadding, + container = { + OutlinedTextFieldDefaults.Container( + enabled = enabled, + isError = isError, + interactionSource = interactionSource, + colors = colors, + ) + }, + ) + }, + ) + + // Chips overlay at the top of the text field + if (selectedOptions.isNotEmpty()) { + FlowRow( + modifier = + Modifier.fillMaxWidth() + .padding(chipMargin) + .padding(bottom = chipMarginBottom) + .onSizeChanged { size -> chipContainerHeight = size.height }, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + selectedOptions.forEach { + InputChip( + selected = false, + modifier = Modifier.testTag(MULTI_AUTO_COMPLETE_INPUT_CHIP_TAG), + enabled = enabled, + onClick = { onOptionDeselected(it) }, + label = { Text(it.answerOptionAnnotatedString()) }, + trailingIcon = { + Icon( + painterResource(R.drawable.ic_clear), + contentDescription = "Remove ${it.answerOptionString}", + ) + }, + ) + } + } + } + } + + if (filteredOptions.isNotEmpty()) { + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + filteredOptions.forEach { option -> + DropDownAnswerMenuItem(enabled, option) { + autoCompleteText = TextFieldValue("") // Reset autoComplete text to empty + onNewOptionSelected(option) + expanded = false + } + } + } + } + } +} + +const val MULTI_AUTO_COMPLETE_TEXT_FIELD_TAG = "multi_auto_complete_text_field" +const val MULTI_AUTO_COMPLETE_INPUT_CHIP_TAG = "multi_auto_complete_input_chip" diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/SliderItem.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/SliderItem.kt new file mode 100644 index 0000000000..397f07e4ef --- /dev/null +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/SliderItem.kt @@ -0,0 +1,96 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.datacapture.views.compose + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsDraggedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt + +@Composable +fun SliderItem( + startPosition: Float, + steps: Int, + valueRange: ClosedFloatingPointRange, + enabled: Boolean, + onPositionChanged: (Float) -> Unit, +) { + var sliderPosition by remember(startPosition) { mutableFloatStateOf(startPosition) } + val interactionSource = + remember(startPosition, steps, valueRange, enabled) { MutableInteractionSource() } + val isDragged by interactionSource.collectIsDraggedAsState() + + Box { + if (isDragged) { + Surface( + modifier = + Modifier.layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val sliderWidth = constraints.maxWidth + val sliderValueNormalized = + (sliderPosition - valueRange.start) / (valueRange.endInclusive - valueRange.start) + val textX = + (sliderWidth * sliderValueNormalized - placeable.width / 2).coerceIn( + 0f, + (sliderWidth - placeable.width).toFloat(), + ) + val bottomPadding = 16.dp.toPx().roundToInt() + layout(placeable.width, placeable.height - bottomPadding) { + placeable.placeRelative(textX.roundToInt(), -bottomPadding) + } + }, + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.primary, + ) { + Text( + text = sliderPosition.roundToInt().toString(), + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) + } + } + + Slider( + value = sliderPosition, + onValueChange = { sliderPosition = it }, + onValueChangeFinished = { onPositionChanged(sliderPosition) }, + steps = steps, + valueRange = valueRange, + interactionSource = interactionSource, + modifier = Modifier.padding(top = 16.dp).fillMaxWidth().testTag(SLIDER_TAG), + enabled = enabled, + ) + } +} + +const val SLIDER_TAG = "slider" diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/TimePickerDialog.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/TimePickerDialog.kt new file mode 100644 index 0000000000..f245d5628f --- /dev/null +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/TimePickerDialog.kt @@ -0,0 +1,132 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.datacapture.views.compose + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimeInput +import androidx.compose.material3.TimePicker +import androidx.compose.material3.rememberTimePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.google.android.fhir.datacapture.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TimePickerDialog( + type: TimeInputMode, + initialSelectedHour: Int = 0, + initialSelectedMinute: Int = 0, + onDismiss: () -> Unit, + onConfirm: (Int, Int) -> Unit, +) { + val timePickerState = + rememberTimePickerState( + initialHour = initialSelectedHour, + initialMinute = initialSelectedMinute, + ) + var inputType by remember(type) { mutableStateOf(type) } + + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton( + onClick = { + onConfirm(timePickerState.hour, timePickerState.minute) + onDismiss() + }, + ) { + Text("OK") + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + text = { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (inputType == TimeInputMode.CLOCK) { + TimePicker(state = timePickerState) + } else { + TimeInput(state = timePickerState) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = { + inputType = + if (inputType == TimeInputMode.CLOCK) { + TimeInputMode.KEYBOARD + } else { + TimeInputMode.CLOCK + } + }, + ) { + val iconRes = + if (inputType == TimeInputMode.CLOCK) { + R.drawable.ic_keyboard + } else { + R.drawable.ic_access_time + } + Icon( + painterResource(iconRes), + contentDescription = + if (inputType == TimeInputMode.CLOCK) { + "Switch to text input" + } else { + "Switch to clock input" + }, + ) + } + } + } + }, + title = { Text(stringResource(R.string.select_time)) }, + ) +} + +sealed interface TimeInputMode { + object KEYBOARD : TimeInputMode + + object CLOCK : TimeInputMode +} + +@Preview +@Composable +fun TimePickerDialogPreview() { + TimePickerDialog(onDismiss = {}, type = TimeInputMode.KEYBOARD, onConfirm = { _, _ -> }) +} diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/TimePickerItem.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/TimePickerItem.kt new file mode 100644 index 0000000000..6e2c0c9e71 --- /dev/null +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/TimePickerItem.kt @@ -0,0 +1,156 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.datacapture.views.compose + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +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.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.error +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import com.google.android.fhir.datacapture.R +import com.google.android.fhir.datacapture.extensions.toLocalizedString +import java.time.LocalTime + +@Composable +internal fun TimePickerItem( + modifier: Modifier = Modifier, + timeSelectedDisplay: String?, + initialStartTime: LocalTime, + enabled: Boolean, + hint: String, + supportingHelperText: String?, + isError: Boolean, + onTimeChanged: (LocalTime) -> Unit, +) { + val context = LocalContext.current + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + var selectedTimeTextDisplay by + remember(timeSelectedDisplay) { mutableStateOf(timeSelectedDisplay ?: "") } + var showTimePickerModal by remember { mutableStateOf(false) } + var timePickerDialogType by remember { mutableStateOf(TimeInputMode.CLOCK) } + + OutlinedTextField( + value = selectedTimeTextDisplay, + onValueChange = {}, + singleLine = true, + label = { Text(hint) }, + modifier = + modifier + .testTag(TIME_PICKER_INPUT_FIELD) + .onFocusChanged { + if (!it.isFocused) { + keyboardController?.hide() + } + } + .semantics { if (isError) error(supportingHelperText ?: "") }, + supportingText = { supportingHelperText?.let { Text(it) } }, + isError = isError, + trailingIcon = { + IconButton( + onClick = { + timePickerDialogType = TimeInputMode.CLOCK + showTimePickerModal = true + }, + enabled = enabled, + ) { + Icon( + painterResource(R.drawable.gm_schedule_24), + contentDescription = stringResource(R.string.select_time), + ) + } + }, + readOnly = true, + enabled = enabled, + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), + keyboardActions = + KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Down) }, + ), + interactionSource = + remember { MutableInteractionSource() } + .also { interactionSource -> + LaunchedEffect(interactionSource) { + interactionSource.interactions.collect { + if (it is PressInteraction.Release) { + timePickerDialogType = TimeInputMode.KEYBOARD + showTimePickerModal = true + } + } + } + }, + ) + + if (showTimePickerModal) { + TimePickerDialog( + type = timePickerDialogType, + initialSelectedHour = initialStartTime.hour, + initialSelectedMinute = initialStartTime.minute, + onDismiss = { showTimePickerModal = false }, + ) { hour, min, + -> + val localTime = LocalTime.of(hour, min) + selectedTimeTextDisplay = localTime.toLocalizedString(context) + onTimeChanged(localTime) + } + } +} + +@Composable +@Preview +fun PreviewTimePickerItem() { + val context = LocalContext.current + TimePickerItem( + Modifier, + null, + LocalTime.now(), + true, + stringResource(R.string.time), + null, + false, + ) {} +} + +const val TIME_PICKER_INPUT_FIELD = "time_picker_text_field" diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/AutoCompleteViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/AutoCompleteViewHolderFactory.kt index e47e32198e..f60e07e2e3 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/AutoCompleteViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/AutoCompleteViewHolderFactory.kt @@ -16,233 +16,152 @@ package com.google.android.fhir.datacapture.views.factories -import android.view.View -import android.widget.AdapterView -import android.widget.ArrayAdapter -import android.widget.TextView -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.children -import androidx.core.view.get -import androidx.core.view.isEmpty -import androidx.lifecycle.lifecycleScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.extensions.displayString import com.google.android.fhir.datacapture.extensions.identifierString -import com.google.android.fhir.datacapture.extensions.tryUnwrapContext +import com.google.android.fhir.datacapture.extensions.itemMedia import com.google.android.fhir.datacapture.validation.Invalid -import com.google.android.fhir.datacapture.validation.NotValidated -import com.google.android.fhir.datacapture.validation.Valid -import com.google.android.fhir.datacapture.validation.ValidationResult -import com.google.android.fhir.datacapture.views.HeaderView import com.google.android.fhir.datacapture.views.QuestionnaireViewItem -import com.google.android.material.chip.Chip -import com.google.android.material.chip.ChipGroup -import com.google.android.material.textfield.MaterialAutoCompleteTextView -import com.google.android.material.textfield.TextInputLayout +import com.google.android.fhir.datacapture.views.compose.Header +import com.google.android.fhir.datacapture.views.compose.MediaItem +import com.google.android.fhir.datacapture.views.compose.MultiAutoCompleteTextItem +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.QuestionnaireResponse -internal object AutoCompleteViewHolderFactory : - QuestionnaireItemAndroidViewHolderFactory(R.layout.edit_text_auto_complete_view) { +internal object AutoCompleteViewHolderFactory : QuestionnaireItemComposeViewHolderFactory { override fun getQuestionnaireItemViewHolderDelegate() = - object : QuestionnaireItemAndroidViewHolderDelegate { - private lateinit var context: AppCompatActivity - private lateinit var header: HeaderView - private lateinit var autoCompleteTextView: MaterialAutoCompleteTextView - private lateinit var chipContainer: ChipGroup - private lateinit var textInputLayout: TextInputLayout - private val canHaveMultipleAnswers - get() = questionnaireViewItem.questionnaireItem.repeats - - override lateinit var questionnaireViewItem: QuestionnaireViewItem - private lateinit var errorTextView: TextView - - override fun init(itemView: View) { - context = itemView.context.tryUnwrapContext()!! - header = itemView.findViewById(R.id.header) - autoCompleteTextView = itemView.findViewById(R.id.autoCompleteTextView) - chipContainer = itemView.findViewById(R.id.chipContainer) - textInputLayout = itemView.findViewById(R.id.text_input_layout) - errorTextView = itemView.findViewById(R.id.error) - autoCompleteTextView.onItemClickListener = - AdapterView.OnItemClickListener { _, _, position, _ -> - val answer = - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { - value = - questionnaireViewItem.enabledAnswerOptions - .first { - it.value.identifierString(header.context) == - (autoCompleteTextView.adapter.getItem(position) - as AutoCompleteViewAnswerOption) - .answerId - } - .valueCoding - } - - onAnswerSelected(answer) - autoCompleteTextView.setText("") + object : QuestionnaireItemComposeViewHolderDelegate { + + @Composable + override fun Content(questionnaireViewItem: QuestionnaireViewItem) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope { Dispatchers.Main } + val canHaveMultipleAnswers = + remember(questionnaireViewItem.questionnaireItem) { + questionnaireViewItem.questionnaireItem.repeats } - } - - override fun bind(questionnaireViewItem: QuestionnaireViewItem) { - header.bind(questionnaireViewItem, showRequiredOrOptionalText = true) - val answerOptionValues = - questionnaireViewItem.enabledAnswerOptions.map { - AutoCompleteViewAnswerOption( - answerId = it.value.identifierString(header.context), - answerDisplay = it.value.displayString(header.context), + val enabledAnswerOptions = + remember(questionnaireViewItem.enabledAnswerOptions) { + questionnaireViewItem.enabledAnswerOptions.map { + DropDownAnswerOption( + answerId = it.value.identifierString(context), + answerOptionString = it.value.displayString(context), + ) + } + } + var selectedAnswerOptions by + remember(questionnaireViewItem.answers) { + mutableStateOf( + questionnaireViewItem.answers.map { + DropDownAnswerOption( + answerId = it.value.identifierString(context), + answerOptionString = it.value.displayString(context), + ) + }, ) } - val adapter = - ArrayAdapter( - header.context, - R.layout.drop_down_list_item, - R.id.answer_option_textview, - answerOptionValues, - ) - autoCompleteTextView.setAdapter(adapter) - // Remove chips if any from the last bindView call on this VH. - chipContainer.removeAllViews() - presetValuesIfAny() - - displayValidationResult(questionnaireViewItem.validationResult) - } - - override fun setReadOnly(isReadOnly: Boolean) { - for (i in 0 until chipContainer.childCount) { - val view = chipContainer.getChildAt(i) - view.isEnabled = !isReadOnly - if (view is Chip && isReadOnly) { - view.setOnCloseIconClickListener(null) + val errorTextMessage = + remember(questionnaireViewItem.validationResult) { + (questionnaireViewItem.validationResult as? Invalid) + ?.getSingleStringValidationMessage() + ?.takeIf { it.isNotBlank() } } - } - textInputLayout.isEnabled = !isReadOnly - } - - private fun presetValuesIfAny() { - questionnaireViewItem.answers.map { answer -> addNewChipIfNotPresent(answer) } - } - - private fun onAnswerSelected( - answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, - ) { - if (canHaveMultipleAnswers) { - handleSelectionWhenQuestionCanHaveMultipleAnswers(answer) - } else { - handleSelectionWhenQuestionCanHaveSingleAnswer(answer) - } - } - - /** - * Adds a new chip if it not already present in [chipContainer].It returns [true] if a new - * Chip is added and [false] if the Chip is already present for the selected answer. The later - * will happen if the user selects an already selected answer. - */ - private fun addNewChipIfNotPresent( - answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, - ): Boolean { - if (chipIsAlreadyPresent(answer)) return false - - val chip = Chip(chipContainer.context, null, R.attr.questionnaireChipStyle) - chip.id = View.generateViewId() - chip.text = answer.valueCoding.displayOrCode - chip.isCloseIconVisible = true - chip.isClickable = true - chip.isCheckable = false - chip.tag = answer - chip.setOnCloseIconClickListener { - chipContainer.removeView(chip) - onChipRemoved(chip) - } - - chipContainer.addView(chip) - return true - } - - private fun chipIsAlreadyPresent( - answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, - ): Boolean { - return chipContainer.children.any { chip -> - (chip.tag as QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent) - .value - .equalsDeep(answer.value) - } - } - - private fun handleSelectionWhenQuestionCanHaveSingleAnswer( - answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, - ) { - if (chipContainer.isEmpty()) { - addNewChipIfNotPresent(answer) - } else { - (chipContainer[0] as Chip).apply { - text = answer.valueCoding.displayOrCode - tag = answer + val isReadOnly = + remember(questionnaireViewItem.questionnaireItem) { + questionnaireViewItem.questionnaireItem.readOnly } - } - context.lifecycleScope.launch { questionnaireViewItem.setAnswer(answer) } - } - - private fun handleSelectionWhenQuestionCanHaveMultipleAnswers( - answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, - ) { - val answerNotPresent = - questionnaireViewItem.answers.none { it.value.equalsDeep(answer.value) } - if (answerNotPresent) { - addNewChipIfNotPresent(answer) - context.lifecycleScope.launch { questionnaireViewItem.addAnswer(answer) } - } - } - - private fun onChipRemoved(chip: Chip) { - context.lifecycleScope.launch { - if (canHaveMultipleAnswers) { - (chip.tag as QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent).let { - questionnaireViewItem.removeAnswer(it) - } - } else { - questionnaireViewItem.clearAnswer() - } - } - } - - private fun displayValidationResult(validationResult: ValidationResult) { - // https://github.com/material-components/material-components-android/issues/1435 - // Because of the above issue, we use separate error textview. But we still use - // textInputLayout to show the error icon and the box color. - when (validationResult) { - is NotValidated, - Valid, -> { - errorTextView.visibility = View.GONE - textInputLayout.error = null - } - is Invalid -> { - errorTextView.text = validationResult.getSingleStringValidationMessage() - errorTextView.visibility = View.VISIBLE - textInputLayout.error = " " // non empty text - } + Column( + modifier = + Modifier.fillMaxWidth() + .padding( + horizontal = dimensionResource(R.dimen.item_margin_horizontal), + vertical = dimensionResource(R.dimen.item_margin_vertical), + ), + ) { + Header(questionnaireViewItem, showRequiredOrOptionalText = true) + questionnaireViewItem.questionnaireItem.itemMedia?.let { MediaItem(it) } + + // TODO: Set text gravity using Modifier + // .align(Alignment.BottomCenter) + MultiAutoCompleteTextItem( + modifier = Modifier.fillMaxWidth(), + enabled = !isReadOnly, + supportingText = errorTextMessage, + isError = errorTextMessage.isNullOrBlank().not(), + options = enabledAnswerOptions, + selectedOptions = selectedAnswerOptions, + onNewOptionSelected = { answerOption -> + selectedAnswerOptions = + if (canHaveMultipleAnswers) { + if (answerOption in selectedAnswerOptions) { + selectedAnswerOptions + } else { + selectedAnswerOptions + answerOption + } + } else { + listOf(answerOption) + } + + val questionnaireResponseAnswer = + questionnaireViewItem.enabledAnswerOptions + .first { it.value.identifierString(context) == answerOption.answerId } + .valueCoding + .let { + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = it + } + } + + val answerNotPresent = + questionnaireViewItem.answers.none { + it.value.equalsDeep(questionnaireResponseAnswer.value) + } + if (answerNotPresent) { + coroutineScope.launch { + if (canHaveMultipleAnswers) { + questionnaireViewItem.addAnswer(questionnaireResponseAnswer) + } else { + questionnaireViewItem.setAnswer(questionnaireResponseAnswer) + } + } + } + }, + onOptionDeselected = { option -> + selectedAnswerOptions = selectedAnswerOptions.filterNot { it == option } + + val answerOptionCoding = + questionnaireViewItem.enabledAnswerOptions + .first { it.value.identifierString(context) == option.answerId } + .valueCoding + coroutineScope.launch { + if (canHaveMultipleAnswers) { + questionnaireViewItem.removeAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = answerOptionCoding + }, + ) + } else { + questionnaireViewItem.clearAnswer() + } + } + }, + ) } } - - private val Coding.displayOrCode: String - get() = - if (display.isNullOrBlank()) { - code - } else { - display - } } } - -/** - * An answer option that would show up as a dropdown item in an [AutoCompleteViewHolderFactory] - * textview - */ -internal data class AutoCompleteViewAnswerOption(val answerId: String, val answerDisplay: String) { - override fun toString(): String { - return this.answerDisplay - } -} diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DatePickerViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DatePickerViewHolderFactory.kt index 304c9b7e63..a33b5ab404 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DatePickerViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DatePickerViewHolderFactory.kt @@ -16,239 +16,205 @@ package com.google.android.fhir.datacapture.views.factories -import android.annotation.SuppressLint import android.content.Context -import android.text.Editable -import android.text.TextWatcher -import android.view.View -import android.view.inputmethod.InputMethodManager -import androidx.appcompat.app.AppCompatActivity -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.SelectableDates +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.extensions.canonicalizeDatePattern import com.google.android.fhir.datacapture.extensions.dateEntryFormatOrSystemDefault +import com.google.android.fhir.datacapture.extensions.dateType import com.google.android.fhir.datacapture.extensions.format import com.google.android.fhir.datacapture.extensions.getDateSeparator import com.google.android.fhir.datacapture.extensions.getRequiredOrOptionalText import com.google.android.fhir.datacapture.extensions.getValidationErrorMessage -import com.google.android.fhir.datacapture.extensions.parseDate -import com.google.android.fhir.datacapture.extensions.tryUnwrapContext +import com.google.android.fhir.datacapture.extensions.itemMedia +import com.google.android.fhir.datacapture.extensions.localDate +import com.google.android.fhir.datacapture.extensions.parseLocalDateOrNull +import com.google.android.fhir.datacapture.extensions.toLocalDate import com.google.android.fhir.datacapture.validation.Invalid -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.datepicker.CalendarConstraints -import com.google.android.material.datepicker.CalendarConstraints.DateValidator -import com.google.android.material.datepicker.CompositeDateValidator -import com.google.android.material.datepicker.DateValidatorPointBackward -import com.google.android.material.datepicker.DateValidatorPointForward +import com.google.android.fhir.datacapture.views.compose.DateInput +import com.google.android.fhir.datacapture.views.compose.DateInputFormat +import com.google.android.fhir.datacapture.views.compose.DatePickerItem +import com.google.android.fhir.datacapture.views.compose.Header +import com.google.android.fhir.datacapture.views.compose.MediaItem import com.google.android.material.datepicker.MaterialDatePicker -import com.google.android.material.textfield.TextInputEditText -import com.google.android.material.textfield.TextInputLayout -import java.text.ParseException -import java.time.Instant import java.time.LocalDate import java.time.ZoneId -import java.time.format.DateTimeParseException -import java.util.Date -import kotlin.math.abs -import kotlin.math.log10 +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.hl7.fhir.r4.model.DateType import org.hl7.fhir.r4.model.QuestionnaireResponse -internal object DatePickerViewHolderFactory : - QuestionnaireItemAndroidViewHolderFactory(R.layout.date_picker_view) { +internal object DatePickerViewHolderFactory : QuestionnaireItemComposeViewHolderFactory { + @OptIn(ExperimentalMaterial3Api::class) override fun getQuestionnaireItemViewHolderDelegate() = - object : QuestionnaireItemAndroidViewHolderDelegate { - private lateinit var context: AppCompatActivity - private lateinit var header: HeaderView - private lateinit var textInputLayout: TextInputLayout - private lateinit var textInputEditText: TextInputEditText - override lateinit var questionnaireViewItem: QuestionnaireViewItem - private lateinit var canonicalizedDatePattern: String - private var textWatcher: TextWatcher? = null - - override fun init(itemView: View) { - context = 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) - textInputEditText.setOnFocusChangeListener { view, hasFocus -> - if (!hasFocus) { - (view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager) - .hideSoftInputFromWindow(view.windowToken, 0) + object : QuestionnaireItemComposeViewHolderDelegate { + + @Composable + override fun Content(questionnaireViewItem: QuestionnaireViewItem) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope { Dispatchers.Main } + val dateEntryFormat = + remember(questionnaireViewItem) { + questionnaireViewItem.questionnaireItem.dateEntryFormatOrSystemDefault } - } - textInputLayout.setEndIconOnClickListener { - // The application is wrapped in a ContextThemeWrapper in QuestionnaireFragment - // and again in TextInputEditText during layout inflation. As a result, it is - // necessary to access the base context twice to retrieve the application object - // from the view's context. - val context = itemView.context.tryUnwrapContext()!! - val localDateInput = + val datePatternSeparator = + remember(dateEntryFormat) { getDateSeparator(dateEntryFormat) ?: '/' } + val canonicalizedDatePattern = + remember(dateEntryFormat) { canonicalizeDatePattern(dateEntryFormat) } + val uiDatePatternText = + remember(canonicalizedDatePattern) { + // Use 'mm' for month instead of 'MM' to avoid confusion. + // See https://developer.android.com/reference/kotlin/java/text/SimpleDateFormat. + canonicalizedDatePattern.lowercase() + } + val dateInputFormat = + remember(canonicalizedDatePattern, datePatternSeparator) { + DateInputFormat( + canonicalizedDatePattern, + datePatternSeparator, + ) + } + val questionnaireItemAnswerLocalDate = + remember(questionnaireViewItem.answers) { questionnaireViewItem.answers.singleOrNull()?.valueDateType?.localDate - buildMaterialDatePicker(localDateInput) - .apply { - addOnPositiveButtonClickListener { epochMilli -> - with(Instant.ofEpochMilli(epochMilli).atZone(ZONE_ID_UTC).toLocalDate()) { - textInputEditText.setText(this?.format(canonicalizedDatePattern)) - setQuestionnaireItemViewItemAnswer(this) - } - // Clear focus so that the user can refocus to open the dialog - textInputEditText.clearFocus() - } - } - .show(context.supportFragmentManager, TAG) - } - } - - @SuppressLint("NewApi") // java.time APIs can be used due to desugaring - override fun bind(questionnaireViewItem: QuestionnaireViewItem) { - clearPreviousState() - header.bind(questionnaireViewItem) - - val datePattern = questionnaireViewItem.questionnaireItem.dateEntryFormatOrSystemDefault - // Special character used in date pattern - val datePatternSeparator = getDateSeparator(datePattern) - canonicalizedDatePattern = canonicalizeDatePattern(datePattern) - - with(textInputLayout) { - // Use 'mm' for month instead of 'MM' to avoid confusion. - // See https://developer.android.com/reference/kotlin/java/text/SimpleDateFormat. - hint = canonicalizedDatePattern.lowercase() - helperText = getRequiredOrOptionalText(questionnaireViewItem, context) - } - textInputEditText.removeTextChangedListener(textWatcher) - - val questionnaireItemViewItemDateAnswer = - questionnaireViewItem.answers.singleOrNull()?.valueDateType?.localDate - - val dateStringToDisplay = - questionnaireItemViewItemDateAnswer?.format(canonicalizedDatePattern) - ?: questionnaireViewItem.draftAnswer as? String + } + val questionnaireItemAnswerDateInMillis = + remember(questionnaireItemAnswerLocalDate) { + questionnaireItemAnswerLocalDate?.atStartOfDay(ZONE_ID_UTC)?.toInstant()?.toEpochMilli() + } + val initialSelectedDateInMillis = + remember(questionnaireItemAnswerDateInMillis) { + questionnaireItemAnswerDateInMillis ?: MaterialDatePicker.todayInUtcMilliseconds() + } + val draftAnswer = + remember(questionnaireViewItem) { questionnaireViewItem.draftAnswer as? String } + val dateInput = + remember(dateInputFormat, questionnaireItemAnswerLocalDate, draftAnswer) { + questionnaireItemAnswerLocalDate + ?.format(dateInputFormat.patternWithoutDelimiters) + ?.let { DateInput(it, questionnaireItemAnswerLocalDate) } + ?: DateInput(display = draftAnswer ?: "", null) + } - if (textInputEditText.text.toString() != dateStringToDisplay) { - textInputEditText.setText(dateStringToDisplay) - } + val selectableDatesResult = + remember(questionnaireViewItem) { getSelectableDates(questionnaireViewItem) } + + val selectableDates = remember(selectableDatesResult) { selectableDatesResult.getOrNull() } + + val prohibitInput = remember(selectableDatesResult) { selectableDatesResult.isFailure } + + val validationMessage = + remember(draftAnswer, selectableDatesResult) { + if (selectableDatesResult.isFailure) { + selectableDatesResult.exceptionOrNull()?.localizedMessage + } else { + // If the draft answer is set, this means the user has yet to type a parseable answer, + // so we display an error. + getValidationErrorMessage( + context, + questionnaireViewItem, + if (!draftAnswer.isNullOrEmpty()) { + Invalid( + listOf(invalidDateErrorText(context, canonicalizedDatePattern)), + ) + } else { + questionnaireViewItem.validationResult + }, + ) + } + } - // If the draft answer is set, this means the user has yet to type a parseable answer, - // so we display an error. - val draftAnswer = questionnaireViewItem.draftAnswer as? String - if (!draftAnswer.isNullOrEmpty()) { - displayValidationResult( - Invalid( - listOf(invalidDateErrorText(textInputEditText.context, canonicalizedDatePattern)), + Column( + modifier = + Modifier.padding( + horizontal = dimensionResource(R.dimen.item_margin_horizontal), + vertical = dimensionResource(R.dimen.item_margin_vertical), ), + ) { + Header(questionnaireViewItem) + questionnaireViewItem.questionnaireItem.itemMedia?.let { MediaItem(it) } + + DatePickerItem( + modifier = Modifier.fillMaxWidth(), + initialSelectedDateMillis = initialSelectedDateInMillis, + selectableDates = selectableDates, + dateInputFormat = dateInputFormat, + dateInput = dateInput, + labelText = uiDatePatternText, + helperText = validationMessage.takeIf { !it.isNullOrBlank() } + ?: getRequiredOrOptionalText(questionnaireViewItem, context), + isError = !validationMessage.isNullOrBlank(), + enabled = !(questionnaireViewItem.questionnaireItem.readOnly || prohibitInput), + parseStringToLocalDate = { str, pattern -> parseLocalDateOrNull(str, pattern) }, + onDateInputEntry = { + val (display, date) = it + if (date != null) { + coroutineScope.launch { + setQuestionnaireItemViewItemAnswer(questionnaireViewItem, date) + } + } else { + coroutineScope.launch { + parseDateOnTextChanged( + questionnaireViewItem, + display, + dateInputFormat.patternWithoutDelimiters, + ) + } + } + }, ) - } else { - displayValidationResult(questionnaireViewItem.validationResult) } - textWatcher = DatePatternTextWatcher(datePatternSeparator) - textInputEditText.addTextChangedListener(textWatcher) } - override fun setReadOnly(isReadOnly: Boolean) { - textInputEditText.isEnabled = !isReadOnly - textInputLayout.isEnabled = !isReadOnly - } - - private fun buildMaterialDatePicker(localDate: LocalDate?): MaterialDatePicker { - val selectedDateMillis = - localDate?.atStartOfDay(ZONE_ID_UTC)?.toInstant()?.toEpochMilli() - ?: MaterialDatePicker.todayInUtcMilliseconds() - - return MaterialDatePicker.Builder.datePicker() - .setTitleText(R.string.select_date) - .setSelection(selectedDateMillis) - .setCalendarConstraints(getCalenderConstraint()) - .build() - } - - private fun getCalenderConstraint(): CalendarConstraints { + private fun getSelectableDates( + questionnaireViewItem: QuestionnaireViewItem, + ): Result { val min = (questionnaireViewItem.minAnswerValue as? DateType)?.value?.time val max = (questionnaireViewItem.maxAnswerValue as? DateType)?.value?.time - if (min != null && max != null && min > max) { - throw IllegalArgumentException("minValue cannot be greater than maxValue") + return if (min != null && max != null && min > max) { + Result.failure(IllegalArgumentException("minValue cannot be greater than maxValue")) + } else { + Result.success(selectableDates(min, max)) } - - val listValidators = ArrayList() - min?.let { listValidators.add(DateValidatorPointForward.from(it)) } - max?.let { listValidators.add(DateValidatorPointBackward.before(it)) } - val validators = CompositeDateValidator.allOf(listValidators) - - return CalendarConstraints.Builder().setValidator(validators).build() - } - - private fun clearPreviousState() { - textInputEditText.isEnabled = true - textInputLayout.isEnabled = true } /** Set the answer in the [QuestionnaireResponse]. */ - private fun setQuestionnaireItemViewItemAnswer(localDate: LocalDate) = - context.lifecycleScope.launch { - questionnaireViewItem.setAnswer( - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { - value = localDate.dateType - }, - ) - } + private suspend fun setQuestionnaireItemViewItemAnswer( + questionnaireViewItem: QuestionnaireViewItem, + localDate: LocalDate, + ) = + questionnaireViewItem.setAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = localDate.dateType + }, + ) /** * Each time the user types in a character, parse the string and if it can be parsed into a * date, set the answer in the [QuestionnaireResponse], otherwise, set the draft answer. */ - private fun parseDateOnTextChanged(dateToDisplay: String) = - context.lifecycleScope.launch { - try { - val localDate = parseDate(dateToDisplay, canonicalizedDatePattern) - setQuestionnaireItemViewItemAnswer(localDate) - } catch (e: ParseException) { - questionnaireViewItem.setDraftAnswer(dateToDisplay) - } catch (e: DateTimeParseException) { - questionnaireViewItem.setDraftAnswer(dateToDisplay) - } - } - - private fun displayValidationResult(validationResult: ValidationResult) { - textInputLayout.error = - getValidationErrorMessage( - textInputLayout.context, - questionnaireViewItem, - validationResult, - ) - } - - /** Automatically appends date separator (e.g. "/") during date input. */ - inner class DatePatternTextWatcher(private val dateFormatSeparator: Char?) : TextWatcher { - private var isDeleting = false - - override fun beforeTextChanged( - charSequence: CharSequence, - start: Int, - count: Int, - after: Int, - ) { - isDeleting = count > after - } - - override fun onTextChanged( - charSequence: CharSequence, - start: Int, - before: Int, - count: Int, - ) {} - - override fun afterTextChanged(editable: Editable) { - handleDateFormatAfterTextChange( - editable, - canonicalizedDatePattern, - dateFormatSeparator, - isDeleting, - ) - parseDateOnTextChanged(editable.toString()) + private suspend fun parseDateOnTextChanged( + questionnaireViewItem: QuestionnaireViewItem, + dateToDisplay: String, + pattern: String, + ) { + val localDate = parseLocalDateOrNull(dateToDisplay, pattern) + if (localDate != null) { + setQuestionnaireItemViewItemAnswer(questionnaireViewItem, localDate) + } else { + questionnaireViewItem.setDraftAnswer(dateToDisplay) } } } @@ -257,71 +223,6 @@ internal object DatePickerViewHolderFactory : internal const val TAG = "date-picker" internal val ZONE_ID_UTC = ZoneId.of("UTC") -/** - * Format entered date to acceptable date format where 2 digits for day and month, 4 digits for - * year. - */ -internal fun handleDateFormatAfterTextChange( - editable: Editable, - canonicalizedDatePattern: String, - dateFormatSeparator: Char?, - isDeleting: Boolean, -) { - val editableLength = editable.length - if (editable.isEmpty()) { - return - } - // restrict date entry upto acceptable date length - if (editableLength > canonicalizedDatePattern.length) { - editable.replace(canonicalizedDatePattern.length, editableLength, "") - return - } - // handle delete text and separator - if (editableLength < canonicalizedDatePattern.length) { - // Do not add the separator again if the user has just deleted it. - if (!isDeleting && canonicalizedDatePattern[editableLength] == dateFormatSeparator) { - // 02 is entered with dd/MM/yyyy so appending / to editable 02/ - editable.append(dateFormatSeparator) - } - if ( - canonicalizedDatePattern[editable.lastIndex] == dateFormatSeparator && - editable[editable.lastIndex] != dateFormatSeparator - ) { - // Add separator to break different date components, e.g. converting "123" to "12/3" - editable.insert(editable.lastIndex, dateFormatSeparator.toString()) - } - } -} - -internal val DateType.localDate - get() = - if (!this.hasValue()) { - null - } else { - LocalDate.of( - year, - month + 1, - day, - ) - } - -internal val LocalDate.dateType - get() = DateType(year, monthValue - 1, dayOfMonth) - -internal val Date.localDate - get() = LocalDate.of(year + 1900, month + 1, date) - -// Count the number of digits in an Integer -internal fun Int.length() = - when (this) { - 0 -> 1 - else -> log10(abs(toDouble())).toInt() + 1 - } - -/** - * Replaces 'dd' with '31', 'MM' with '01' and 'yyyy' with '2023' and returns new string. For - * example, given a `formatPattern` of dd/MM/yyyy, returns 31/01/2023 - */ internal fun invalidDateErrorText(context: Context, formatPattern: String) = context.getString( R.string.date_format_validation_error_msg, @@ -330,3 +231,18 @@ internal fun invalidDateErrorText(context: Context, formatPattern: String) = formatPattern.lowercase(), formatPattern.replace("dd", "31").replace("MM", "01").replace("yyyy", "2023"), ) + +@OptIn(ExperimentalMaterial3Api::class) +internal fun selectableDates(minDateMillis: Long?, maxDateMillis: Long?) = + object : SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long) = + (minDateMillis == null || utcTimeMillis >= minDateMillis) && + (maxDateMillis == null || utcTimeMillis <= maxDateMillis) + + private fun getYear(timeInMillis: Long) = timeInMillis.toLocalDate().year + + override fun isSelectableYear(year: Int): Boolean { + return (minDateMillis == null || year >= getYear(minDateMillis)) && + (maxDateMillis == null || year <= getYear(maxDateMillis)) + } + } diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DateTimePickerViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DateTimePickerViewHolderFactory.kt index 43a2112b8d..2845f24e9d 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DateTimePickerViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DateTimePickerViewHolderFactory.kt @@ -16,16 +16,24 @@ package com.google.android.fhir.datacapture.views.factories -import android.annotation.SuppressLint -import android.content.Context -import android.text.Editable -import android.text.InputType -import android.text.TextWatcher -import android.text.format.DateFormat -import android.view.View -import android.view.inputmethod.InputMethodManager -import androidx.appcompat.app.AppCompatActivity -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.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SelectableDates +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.extensions.canonicalizeDatePattern import com.google.android.fhir.datacapture.extensions.format @@ -33,318 +41,216 @@ import com.google.android.fhir.datacapture.extensions.getDateSeparator import com.google.android.fhir.datacapture.extensions.getLocalizedDatePattern import com.google.android.fhir.datacapture.extensions.getRequiredOrOptionalText import com.google.android.fhir.datacapture.extensions.getValidationErrorMessage -import com.google.android.fhir.datacapture.extensions.parseDate +import com.google.android.fhir.datacapture.extensions.itemMedia +import com.google.android.fhir.datacapture.extensions.localDateTime +import com.google.android.fhir.datacapture.extensions.parseLocalDateOrNull import com.google.android.fhir.datacapture.extensions.toLocalizedString -import com.google.android.fhir.datacapture.extensions.tryUnwrapContext import com.google.android.fhir.datacapture.validation.Invalid -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.fhir.datacapture.views.compose.DateInput +import com.google.android.fhir.datacapture.views.compose.DateInputFormat +import com.google.android.fhir.datacapture.views.compose.DatePickerItem +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.TimePickerItem import com.google.android.material.datepicker.MaterialDatePicker -import com.google.android.material.textfield.TextInputEditText -import com.google.android.material.textfield.TextInputLayout -import com.google.android.material.timepicker.MaterialTimePicker -import com.google.android.material.timepicker.MaterialTimePicker.INPUT_MODE_CLOCK -import com.google.android.material.timepicker.MaterialTimePicker.INPUT_MODE_KEYBOARD -import com.google.android.material.timepicker.TimeFormat -import java.text.ParseException -import java.time.Instant -import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime -import java.time.format.DateTimeParseException import java.util.Date +import java.util.Locale +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.hl7.fhir.r4.model.DateTimeType import org.hl7.fhir.r4.model.QuestionnaireResponse -internal object DateTimePickerViewHolderFactory : - QuestionnaireItemAndroidViewHolderFactory(R.layout.date_time_picker_view) { +internal object DateTimePickerViewHolderFactory : QuestionnaireItemComposeViewHolderFactory { + + @OptIn(ExperimentalMaterial3Api::class) override fun getQuestionnaireItemViewHolderDelegate() = - object : QuestionnaireItemAndroidViewHolderDelegate { - private lateinit var context: AppCompatActivity - private lateinit var header: HeaderView - private lateinit var dateInputLayout: TextInputLayout - private lateinit var dateInputEditText: TextInputEditText - private lateinit var timeInputLayout: TextInputLayout - private lateinit var timeInputEditText: TextInputEditText - override lateinit var questionnaireViewItem: QuestionnaireViewItem - private lateinit var canonicalizedDatePattern: String - private lateinit var textWatcher: DatePatternTextWatcher + object : QuestionnaireItemComposeViewHolderDelegate { - override fun init(itemView: View) { - context = itemView.context.tryUnwrapContext()!! - header = itemView.findViewById(R.id.header) - dateInputLayout = itemView.findViewById(R.id.date_input_layout) - dateInputEditText = itemView.findViewById(R.id.date_input_edit_text) - dateInputEditText.setOnFocusChangeListener { view, hasFocus -> - if (!hasFocus) { - (view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager) - .hideSoftInputFromWindow(view.windowToken, 0) + @Composable + override fun Content(questionnaireViewItem: QuestionnaireViewItem) { + val locale = Locale.getDefault() + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope { Dispatchers.Main } + val itemReadOnly = + remember(questionnaireViewItem.questionnaireItem) { + questionnaireViewItem.questionnaireItem.readOnly + } + val localDatePattern = remember(locale) { getLocalizedDatePattern() } + val datePatternSeparator = + remember(localDatePattern) { getDateSeparator(localDatePattern) ?: '/' } + val canonicalizedDatePattern = + remember(localDatePattern) { canonicalizeDatePattern(localDatePattern) } + val uiDatePatternText = + remember(canonicalizedDatePattern) { + // Use 'mm' for month instead of 'MM' to avoid confusion. + // See https://developer.android.com/reference/kotlin/java/text/SimpleDateFormat. + canonicalizedDatePattern.lowercase() + } + val dateInputFormat = + remember(canonicalizedDatePattern, datePatternSeparator) { + DateInputFormat( + canonicalizedDatePattern, + datePatternSeparator, + ) } - } - dateInputLayout.setEndIconOnClickListener { - // The application is wrapped in a ContextThemeWrapper in QuestionnaireFragment - // and again in TextInputEditText during layout inflation. As a result, it is - // necessary to access the base context twice to retrieve the application object - // from the view's context. - val context = itemView.context.tryUnwrapContext()!! - val localDateInput = - questionnaireViewItem.answers.singleOrNull()?.valueDateTimeType?.localDate - buildMaterialDatePicker(localDateInput) - .apply { - addOnPositiveButtonClickListener { epochMilli -> - with(Instant.ofEpochMilli(epochMilli).atZone(ZONE_ID_UTC).toLocalDate()) { - dateInputEditText.setText(this?.format(canonicalizedDatePattern)) - timeInputLayout.isEnabled = true - } - // Clear focus so that the user can refocus to open the dialog - dateInputEditText.clearFocus() - } - } - .show(context.supportFragmentManager, TAG) - } - - timeInputLayout = itemView.findViewById(R.id.time_input_layout) - timeInputEditText = itemView.findViewById(R.id.time_input_edit_text) - timeInputEditText.inputType = InputType.TYPE_NULL - timeInputLayout.isEnabled = false - timeInputLayout.setEndIconOnClickListener { - // The application is wrapped in a ContextThemeWrapper in QuestionnaireFragment - // and again in TextInputEditText during layout inflation. As a result, it is - // necessary to access the base context twice to retrieve the application object - // from the view's context. - val context = itemView.context.tryUnwrapContext()!! - buildMaterialTimePicker(context, INPUT_MODE_CLOCK) - } - timeInputEditText.setOnClickListener { - buildMaterialTimePicker(itemView.context, INPUT_MODE_KEYBOARD) - } - - // This widget does not currently support custom entry format. - val localeDatePattern = getLocalizedDatePattern() - // Special character used in date pattern - val datePatternSeparator = getDateSeparator(localeDatePattern) - textWatcher = DatePatternTextWatcher(datePatternSeparator) - canonicalizedDatePattern = canonicalizeDatePattern(localeDatePattern) - } - - @SuppressLint("NewApi") // java.time APIs can be used due to desugaring - override fun bind(questionnaireViewItem: QuestionnaireViewItem) { - clearPreviousState() - header.bind(questionnaireViewItem) - with(dateInputLayout) { - // Use 'mm' for month instead of 'MM' to avoid confusion. - // See https://developer.android.com/reference/kotlin/java/text/SimpleDateFormat. - hint = canonicalizedDatePattern.lowercase() - helperText = getRequiredOrOptionalText(questionnaireViewItem, context) - } - dateInputEditText.removeTextChangedListener(textWatcher) + val requiredOrOptionalText = + remember(questionnaireViewItem) { + getRequiredOrOptionalText(questionnaireViewItem, context) + } val questionnaireItemViewItemDateTimeAnswer = - questionnaireViewItem.answers.singleOrNull()?.valueDateTimeType?.localDateTime - - val dateStringToDisplay = - questionnaireItemViewItemDateTimeAnswer?.toLocalDate()?.format(canonicalizedDatePattern) - ?: questionnaireViewItem.draftAnswer as? String - - // Determine whether the text field text should be overridden or not. - if (dateInputEditText.text.toString() != dateStringToDisplay) { - dateInputEditText.setText(dateStringToDisplay) - } + remember(questionnaireViewItem.answers) { + questionnaireViewItem.answers.singleOrNull()?.valueDateTimeType?.localDateTime + } + val questionnaireItemViewItemDate = + remember(questionnaireItemViewItemDateTimeAnswer) { + questionnaireItemViewItemDateTimeAnswer?.toLocalDate() + } + val questionnaireViewItemLocalTime = + remember(questionnaireItemViewItemDateTimeAnswer) { + questionnaireItemViewItemDateTimeAnswer?.toLocalTime() + } + val questionnaireItemAnswerDateInMillis = + remember(questionnaireItemViewItemDateTimeAnswer) { + questionnaireItemViewItemDateTimeAnswer + ?.toLocalDate() + ?.atStartOfDay(ZONE_ID_UTC) + ?.toInstant() + ?.toEpochMilli() + } + val initialSelectedDateInMillis = + remember(questionnaireItemAnswerDateInMillis) { + questionnaireItemAnswerDateInMillis ?: MaterialDatePicker.todayInUtcMilliseconds() + } + val draftAnswer = + remember(questionnaireViewItem) { questionnaireViewItem.draftAnswer as? String } + val dateInput = + remember(dateInputFormat, questionnaireItemViewItemDate, draftAnswer) { + questionnaireItemViewItemDate?.format(dateInputFormat.patternWithoutDelimiters)?.let { + DateInput(it, questionnaireItemViewItemDate) + } + ?: DateInput(display = draftAnswer ?: "", null) + } - enableOrDisableTimePicker(questionnaireViewItem, dateStringToDisplay) + val questionnaireViewItemLocalTimeAnswerDisplay = + remember(questionnaireViewItemLocalTime) { + questionnaireViewItemLocalTime?.toLocalizedString(context) ?: "" + } + val initialTimeSelection = + remember(questionnaireViewItemLocalTime) { + questionnaireViewItemLocalTime ?: LocalTime.now() + } + var timeInputEnabled by + remember(questionnaireItemViewItemDate) { + mutableStateOf(!itemReadOnly && questionnaireItemViewItemDate != null) + } - // If there is no set answer in the QuestionnaireItemViewItem, make the time field empty. - timeInputEditText.setText( - questionnaireItemViewItemDateTimeAnswer - ?.toLocalTime() - ?.toLocalizedString(timeInputEditText.context) - ?: "", - ) - dateInputEditText.addTextChangedListener(textWatcher) - } + val selectableDates = remember { object : SelectableDates {} } + val dateValidationMessage = + remember(draftAnswer, questionnaireItemViewItemDateTimeAnswer) { + // If the draft answer is set, this means the user has yet to type a parseable answer, + // so we display an error. + getValidationErrorMessage( + context, + questionnaireViewItem, + if (!draftAnswer.isNullOrEmpty()) { + Invalid( + listOf(invalidDateErrorText(context, canonicalizedDatePattern)), + ) + } else { + questionnaireViewItem.validationResult + }, + ) + } - private fun displayDateValidationError(validationResult: ValidationResult) { - dateInputLayout.error = - getValidationErrorMessage( - dateInputLayout.context, - questionnaireViewItem, - validationResult, - ) - } + Column( + modifier = + Modifier.padding( + horizontal = dimensionResource(R.dimen.item_margin_horizontal), + vertical = dimensionResource(R.dimen.item_margin_vertical), + ), + ) { + Header(questionnaireViewItem) + questionnaireViewItem.questionnaireItem.itemMedia?.let { MediaItem(it) } - override fun setReadOnly(isReadOnly: Boolean) { - // The system outside this delegate should only be able to mark it read only. Otherwise, it - // will change the state set by this delegate in bindView(). - if (isReadOnly) { - dateInputEditText.isEnabled = false - dateInputLayout.isEnabled = false - timeInputEditText.isEnabled = false - timeInputLayout.isEnabled = false - } - } + Row(modifier = Modifier.fillMaxWidth()) { + DatePickerItem( + modifier = Modifier.weight(1f), + initialSelectedDateMillis = initialSelectedDateInMillis, + selectableDates = selectableDates, + dateInputFormat = dateInputFormat, + dateInput = dateInput, + labelText = uiDatePatternText, + helperText = dateValidationMessage.takeIf { !it.isNullOrBlank() } + ?: requiredOrOptionalText, + isError = !dateValidationMessage.isNullOrBlank(), + enabled = !itemReadOnly, + parseStringToLocalDate = { str, pattern -> parseLocalDateOrNull(str, pattern) }, + onDateInputEntry = { + val (display, date) = it + coroutineScope.launch { + if (date != null) { + val dateTime = + LocalDateTime.of( + date, + LocalTime.of(0, 0), + ) + setQuestionnaireItemViewItemAnswer(questionnaireViewItem, dateTime) + } else { + questionnaireViewItem.setDraftAnswer(display) + } + } - private fun buildMaterialDatePicker(localDate: LocalDate?): MaterialDatePicker { - val selectedDateMillis = - localDate?.atStartOfDay(ZONE_ID_UTC)?.toInstant()?.toEpochMilli() - ?: MaterialDatePicker.todayInUtcMilliseconds() + timeInputEnabled = date != null + }, + ) - return MaterialDatePicker.Builder.datePicker() - .setTitleText(R.string.select_date) - .setSelection(selectedDateMillis) - .build() - } + Spacer(Modifier.width(dimensionResource(R.dimen.date_picker_and_time_picker_gap))) - private fun buildMaterialTimePicker(context: Context, inputMode: Int) { - val selectedTime = - questionnaireViewItem.answers.singleOrNull()?.valueDateTimeType?.localTime - ?: LocalTime.now() - val timeFormat = - if (DateFormat.is24HourFormat(context)) { - TimeFormat.CLOCK_24H - } else { - TimeFormat.CLOCK_12H - } - MaterialTimePicker.Builder() - .setTitleText(R.string.select_time) - .setHour(selectedTime.hour) - .setMinute(selectedTime.minute) - .setTimeFormat(timeFormat) - .setInputMode(inputMode) - .build() - .apply { - addOnPositiveButtonClickListener { - with(LocalTime.of(this.hour, this.minute, 0)) { - timeInputEditText.setText(this.toLocalizedString(context)) - setQuestionnaireItemViewItemAnswer( + TimePickerItem( + modifier = Modifier.weight(0.6f), + initialStartTime = initialTimeSelection, + timeSelectedDisplay = questionnaireViewItemLocalTimeAnswerDisplay, + enabled = timeInputEnabled, + hint = stringResource(R.string.time), + supportingHelperText = "", + isError = false, + ) { + coroutineScope.launch { + val dateTime = LocalDateTime.of( - parseDate(dateInputEditText.text.toString(), canonicalizedDatePattern), - this, - ), - ) - timeInputEditText.clearFocus() + questionnaireItemViewItemDate, + it, + ) + setQuestionnaireItemViewItemAnswer(questionnaireViewItem, dateTime) } } } - .show(context.tryUnwrapContext()!!.supportFragmentManager, TAG_TIME_PICKER) - } - - /** Set the answer in the [QuestionnaireResponse]. */ - private fun setQuestionnaireItemViewItemAnswer(localDateTime: LocalDateTime) = - context.lifecycleScope.launch { - questionnaireViewItem.setAnswer( - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent() - .setValue( - DateTimeType( - Date( - localDateTime.year - 1900, - localDateTime.monthValue - 1, - localDateTime.dayOfMonth, - localDateTime.hour, - localDateTime.minute, - localDateTime.second, - ), - ), - ), - ) } - - private fun clearPreviousState() { - dateInputEditText.isEnabled = true - dateInputLayout.isEnabled = true } - /* If the passed in date can be parsed, then enable the time picker, otherwise, keep the time - picker disabled and display an error - */ - private fun enableOrDisableTimePicker( + /** Set the answer in the [QuestionnaireResponse]. */ + private suspend fun setQuestionnaireItemViewItemAnswer( questionnaireViewItem: QuestionnaireViewItem, - dateToDisplay: String?, + localDateTime: LocalDateTime, ) = - try { - if (dateToDisplay != null) { - parseDate(dateToDisplay, canonicalizedDatePattern) - timeInputLayout.isEnabled = true - } - displayDateValidationError(questionnaireViewItem.validationResult) - } catch (e: ParseException) { - timeInputLayout.isEnabled = false - displayDateValidationError( - Invalid( - listOf(invalidDateErrorText(dateInputEditText.context, canonicalizedDatePattern)), - ), - ) - } catch (e: DateTimeParseException) { - timeInputLayout.isEnabled = false - displayDateValidationError( - Invalid( - listOf(invalidDateErrorText(dateInputEditText.context, canonicalizedDatePattern)), + questionnaireViewItem.setAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent() + .setValue( + DateTimeType( + Date( + localDateTime.year - 1900, + localDateTime.monthValue - 1, + localDateTime.dayOfMonth, + localDateTime.hour, + localDateTime.minute, + localDateTime.second, + ), + ), ), - ) - } - - /** Automatically appends date separator (e.g. "/") during date input. */ - inner class DatePatternTextWatcher(private val datePatternSeparator: Char?) : TextWatcher { - private var isDeleting = false - - override fun beforeTextChanged( - charSequence: CharSequence, - start: Int, - count: Int, - after: Int, - ) { - isDeleting = count > after - } - - override fun onTextChanged( - charSequence: CharSequence, - start: Int, - before: Int, - count: Int, - ) {} - - override fun afterTextChanged(editable: Editable) { - handleDateFormatAfterTextChange( - editable, - canonicalizedDatePattern, - datePatternSeparator, - isDeleting, - ) - context.lifecycleScope.launch { - // Always set the draft answer because time is not input yet - questionnaireViewItem.setDraftAnswer(editable.toString()) - } - } - } + ) } } - -private const val TAG_TIME_PICKER = "time-picker" - -internal val DateTimeType.localDate - get() = - LocalDate.of( - year, - month + 1, - day, - ) - -internal val DateTimeType.localTime - get() = - LocalTime.of( - hour, - minute, - second, - ) - -internal val DateTimeType.localDateTime - get() = - LocalDateTime.of( - year, - month + 1, - day, - hour, - minute, - second, - ) 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..b415076432 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 @@ -16,193 +16,139 @@ package com.google.android.fhir.datacapture.views.factories -import android.content.Context import android.graphics.drawable.Drawable import android.text.Spanned -import android.text.method.TextKeyListener -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.inputmethod.InputMethodManager -import android.widget.AdapterView -import android.widget.ArrayAdapter -import android.widget.ImageView -import android.widget.TextView -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.doOnNextLayout -import androidx.lifecycle.lifecycleScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource import com.google.android.fhir.datacapture.R 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.identifierString import com.google.android.fhir.datacapture.extensions.itemAnswerOptionImage -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.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 -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.TextInputLayout +import com.google.android.fhir.datacapture.views.compose.AutoCompleteDropDownItem +import com.google.android.fhir.datacapture.views.compose.Header +import com.google.android.fhir.datacapture.views.compose.MediaItem +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.hl7.fhir.r4.model.QuestionnaireResponse -import timber.log.Timber -internal object DropDownViewHolderFactory : - QuestionnaireItemAndroidViewHolderFactory(R.layout.drop_down_view) { +internal object DropDownViewHolderFactory : QuestionnaireItemComposeViewHolderFactory { override fun getQuestionnaireItemViewHolderDelegate() = - object : QuestionnaireItemAndroidViewHolderDelegate { - private lateinit var header: HeaderView - private lateinit var textInputLayout: TextInputLayout - private lateinit var autoCompleteTextView: MaterialAutoCompleteTextView - private lateinit var clearInputIcon: ImageView - override lateinit var questionnaireViewItem: QuestionnaireViewItem - private lateinit var context: AppCompatActivity + object : QuestionnaireItemComposeViewHolderDelegate { - override fun init(itemView: View) { - header = itemView.findViewById(R.id.header) - textInputLayout = itemView.findViewById(R.id.text_input_layout) - autoCompleteTextView = itemView.findViewById(R.id.auto_complete) - clearInputIcon = itemView.findViewById(R.id.clear_input_icon) - context = itemView.context.tryUnwrapContext()!! - autoCompleteTextView.setOnFocusChangeListener { view, hasFocus -> - if (!hasFocus) { - (view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager) - .hideSoftInputFromWindow(view.windowToken, 0) + @Composable + override fun Content(questionnaireViewItem: QuestionnaireViewItem) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope { Dispatchers.Main } + val hyphen = stringResource(R.string.hyphen) + val isQuestionnaireItemReadOnly = + remember(questionnaireViewItem.questionnaireItem) { + questionnaireViewItem.questionnaireItem.readOnly } - } - clearInputIcon.setOnClickListener { - context.lifecycleScope.launch { - questionnaireViewItem.clearAnswer() - autoCompleteTextView.doOnNextLayout { autoCompleteTextView.showDropDown() } + val flyOverText = + remember(questionnaireViewItem.enabledDisplayItems) { + questionnaireViewItem.enabledDisplayItems.localizedFlyoverAnnotatedString } - } - } - - override fun bind(questionnaireViewItem: QuestionnaireViewItem) { - cleanupOldState() - header.bind(questionnaireViewItem) - with(textInputLayout) { - hint = questionnaireViewItem.enabledDisplayItems.localizedFlyoverSpanned - helperText = getRequiredOrOptionalText(questionnaireViewItem, context) - } - val answerOptionList = - this.questionnaireViewItem.enabledAnswerOptions - .map { + val requiredOptionalText = + remember(questionnaireViewItem) { + getRequiredOrOptionalText(questionnaireViewItem, context) + } + val questionnaireItemAnswerDropDownOptions = + remember(questionnaireViewItem.enabledAnswerOptions) { + questionnaireViewItem.enabledAnswerOptions.map { DropDownAnswerOption( it.value.identifierString(context), it.value.displayString(context), it.itemAnswerOptionImage(context), ) } - .toMutableList() - answerOptionList.add( - 0, - DropDownAnswerOption( - context.getString(R.string.hyphen), - context.getString(R.string.hyphen), - null, - ), - ) - val adapter = - AnswerOptionDropDownArrayAdapter(context, R.layout.drop_down_list_item, answerOptionList) - val selectedAnswerIdentifier = - questionnaireViewItem.answers.singleOrNull()?.value?.identifierString(header.context) - answerOptionList - .firstOrNull { it.answerId == selectedAnswerIdentifier } - ?.let { - autoCompleteTextView.setText(it.answerOptionStringSpanned()) - autoCompleteTextView.setSelection(it.answerOptionStringSpanned().length) - autoCompleteTextView.setCompoundDrawablesRelative( - it.answerOptionImage, - null, - null, - null, + } + val validationErrorMessage = + remember(questionnaireViewItem.validationResult) { + getValidationErrorMessage( + context, + questionnaireViewItem, + questionnaireViewItem.validationResult, ) + ?: "" } - autoCompleteTextView.setAdapter(adapter) - autoCompleteTextView.onItemClickListener = - AdapterView.OnItemClickListener { _, _, position, _ -> - val selectedItem = adapter.getItem(position) - autoCompleteTextView.setText(selectedItem?.answerOptionStringSpanned(), false) - autoCompleteTextView.setCompoundDrawablesRelative( - adapter.getItem(position)?.answerOptionImage, - null, - null, - null, + val showClearInput = + remember(questionnaireViewItem.answers) { questionnaireViewItem.answers.isNotEmpty() } + + val dropDownOptions = + remember(questionnaireItemAnswerDropDownOptions) { + listOf( + DropDownAnswerOption(hyphen, hyphen, null), + *questionnaireItemAnswerDropDownOptions.toTypedArray(), ) + } + val selectedAnswerIdentifier = + remember(questionnaireViewItem.answers) { + questionnaireViewItem.answers.singleOrNull()?.value?.identifierString(context) + } + val selectedOption = + remember(dropDownOptions, selectedAnswerIdentifier) { + questionnaireItemAnswerDropDownOptions.firstOrNull { + it.answerId == selectedAnswerIdentifier + } + } + + 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) } + + AutoCompleteDropDownItem( + modifier = Modifier.fillMaxWidth(), + enabled = !isQuestionnaireItemReadOnly, + labelText = flyOverText, + supportingText = validationErrorMessage.ifBlank { requiredOptionalText }, + isError = validationErrorMessage.isNotBlank(), + showClearIcon = showClearInput, + selectedOption = selectedOption, + options = dropDownOptions, + ) { answerOption -> val selectedAnswer = questionnaireViewItem.enabledAnswerOptions - .firstOrNull { it.value.identifierString(context) == selectedItem?.answerId } + .firstOrNull { it.value.identifierString(context) == answerOption?.answerId } ?.value - context.lifecycleScope.launch { - if (selectedAnswer == null) { - questionnaireViewItem.clearAnswer() - } else { + coroutineScope.launch { + if (selectedAnswer != null) { questionnaireViewItem.setAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent() .setValue(selectedAnswer), ) + } else { + questionnaireViewItem.clearAnswer() } } } - val isEditable = questionnaireViewItem.answers.isEmpty() - if (!isEditable) autoCompleteTextView.clearFocus() - autoCompleteTextView.keyListener = if (isEditable) TextKeyListener.getInstance() else null - clearInputIcon.visibility = if (isEditable) View.GONE else View.VISIBLE - - displayValidationResult(questionnaireViewItem.validationResult) - } - - private fun displayValidationResult(validationResult: ValidationResult) { - textInputLayout.error = - getValidationErrorMessage( - textInputLayout.context, - questionnaireViewItem, - validationResult, - ) - } - - override fun setReadOnly(isReadOnly: Boolean) { - textInputLayout.isEnabled = !isReadOnly - } - - private fun cleanupOldState() { - autoCompleteTextView.setAdapter(null) - autoCompleteTextView.text = null - autoCompleteTextView.setCompoundDrawablesRelative(null, null, null, null) + } } } } -internal class AnswerOptionDropDownArrayAdapter( - context: Context, - private val layoutResourceId: Int, - answerOption: List, -) : ArrayAdapter(context, layoutResourceId, answerOption) { - override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { - val listItemView = - convertView ?: LayoutInflater.from(parent.context).inflate(layoutResourceId, parent, false) - try { - val answerOption: DropDownAnswerOption? = getItem(position) - val answerOptionTextView = - listItemView?.findViewById(R.id.answer_option_textview) as TextView - answerOptionTextView.text = answerOption?.answerOptionStringSpanned() - answerOptionTextView.setCompoundDrawablesRelative( - answerOption?.answerOptionImage, - null, - null, - null, - ) - } catch (e: Exception) { - Timber.w("Could not set data to dropdown UI", e) - } - return listItemView - } -} - internal data class DropDownAnswerOption( val answerId: String, val answerOptionString: String, @@ -213,4 +159,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..c4bf27765d 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,136 +16,138 @@ 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.DropDownItem +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.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() + ?: dropDownOptions.singleOrNull() // Select if has only one option } - 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.Top) { + EditTextFieldItem( + modifier = Modifier.weight(1f), + textFieldState = composeViewQuestionnaireState, + ) + Spacer(modifier = Modifier.width(dimensionResource(R.dimen.item_margin_horizontal))) + DropDownItem( + 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?) { + private suspend fun handleInput( + questionnaireViewItem: QuestionnaireViewItem, + input: UiQuantity, + ) { var decimal: BigDecimal? = null var unit: Coding? = null @@ -153,7 +155,7 @@ internal object QuantityViewHolderFactory : questionnaireViewItem.answers.singleOrNull()?.let { val quantity = it.value as Quantity decimal = quantity.value - unit = Coding(quantity.system, quantity.code, quantity.unit) + unit = quantity.toCoding() } // Read decimal value and unit from partial answer @@ -165,67 +167,55 @@ internal object QuantityViewHolderFactory : } // 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) - }, - ) - } - } + input.value?.let { decimal = it.toBigDecimalOrNull() } + input.unitDropDown?.let { unit = it } - 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) + 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) + }, + ) + } } - - 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 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 uiInputText(questionnaireViewItem: QuestionnaireViewItem): String { + return questionnaireViewItem.answers.singleOrNull()?.valueQuantity?.value?.toString() + ?: questionnaireViewItem.draftAnswer?.let { if (it is BigDecimal) it.toString() else "" } + ?: "" } + + 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/java/com/google/android/fhir/datacapture/views/factories/SliderViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/SliderViewHolderFactory.kt index 4697a07e5c..3978e50f84 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/SliderViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/SliderViewHolderFactory.kt @@ -16,87 +16,95 @@ package com.google.android.fhir.datacapture.views.factories -import android.view.View -import android.widget.TextView -import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.lifecycleScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource import com.google.android.fhir.datacapture.R +import com.google.android.fhir.datacapture.extensions.itemMedia import com.google.android.fhir.datacapture.extensions.sliderStepValue -import com.google.android.fhir.datacapture.extensions.tryUnwrapContext 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.slider.Slider +import com.google.android.fhir.datacapture.views.compose.ErrorText +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.SliderItem +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.hl7.fhir.r4.model.IntegerType import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.Type -internal object SliderViewHolderFactory : - QuestionnaireItemAndroidViewHolderFactory(R.layout.slider_view) { +internal object SliderViewHolderFactory : QuestionnaireItemComposeViewHolderFactory { override fun getQuestionnaireItemViewHolderDelegate() = - object : QuestionnaireItemAndroidViewHolderDelegate { - private lateinit var appContext: AppCompatActivity - private lateinit var header: HeaderView - private lateinit var slider: Slider - private lateinit var error: TextView - override lateinit var questionnaireViewItem: QuestionnaireViewItem + object : QuestionnaireItemComposeViewHolderDelegate { - override fun init(itemView: View) { - appContext = itemView.context.tryUnwrapContext()!! - header = itemView.findViewById(R.id.header) - slider = itemView.findViewById(R.id.slider) - error = itemView.findViewById(R.id.error) - } - - override fun bind(questionnaireViewItem: QuestionnaireViewItem) { - this.questionnaireViewItem = questionnaireViewItem - header.bind(questionnaireViewItem, showRequiredOrOptionalText = true) - val answer = questionnaireViewItem.answers.singleOrNull() - val minValue = getMinValue(questionnaireViewItem.minAnswerValue) - val maxValue = getMaxValue(questionnaireViewItem.maxAnswerValue) - if (minValue >= maxValue) { - throw IllegalStateException("minValue $minValue must be smaller than maxValue $maxValue") - } + @Composable + override fun Content(questionnaireViewItem: QuestionnaireViewItem) { + val validationMessage = + remember(questionnaireViewItem) { + displayValidationResult(questionnaireViewItem.validationResult) + } + val readOnly = + remember(questionnaireViewItem) { questionnaireViewItem.questionnaireItem.readOnly } + val answer = + remember(questionnaireViewItem) { questionnaireViewItem.answers.singleOrNull() } + val minValue = remember(answer) { getMinValue(questionnaireViewItem.minAnswerValue) } + val maxValue = remember(answer) { getMaxValue(questionnaireViewItem.maxAnswerValue) } - with(slider) { - clearOnChangeListeners() - valueFrom = minValue - valueTo = maxValue - stepSize = - (questionnaireViewItem.questionnaireItem.sliderStepValue ?: SLIDER_DEFAULT_STEP_SIZE) - .toFloat() - value = answer?.valueIntegerType?.value?.toFloat() ?: valueFrom + check(minValue < maxValue) { "minValue $minValue must be smaller than maxValue $maxValue" } + val stepSize = + remember(questionnaireViewItem) { + questionnaireViewItem.questionnaireItem.sliderStepValue ?: SLIDER_DEFAULT_STEP_SIZE + } + val steps = + remember(stepSize, minValue, maxValue) { (maxValue - minValue).div(stepSize).toInt() - 1 } + val questionnaireViewItemAnswerValue = + remember(answer) { answer?.valueIntegerType?.value?.toFloat() ?: minValue } + val coroutineScope = rememberCoroutineScope { Dispatchers.Main } - addOnChangeListener { _, newValue, _ -> - appContext.lifecycleScope.launch { - // Responds to when slider's value is changed + Column( + modifier = + Modifier.fillMaxWidth() + .padding( + horizontal = dimensionResource(R.dimen.item_margin_horizontal), + vertical = dimensionResource(R.dimen.item_margin_vertical), + ), + ) { + Header(questionnaireViewItem, showRequiredOrOptionalText = true) + questionnaireViewItem.questionnaireItem.itemMedia?.let { MediaItem(it) } + SliderItem( + startPosition = questionnaireViewItemAnswerValue, + steps = steps, + valueRange = minValue..maxValue, + enabled = !readOnly, + ) { + coroutineScope.launch { questionnaireViewItem.setAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent() - .setValue(IntegerType(newValue.toInt())), + .setValue(IntegerType(it.toInt())), ) } } + validationMessage?.let { ErrorText(it) } } - - displayValidationResult(questionnaireViewItem.validationResult) } - private fun displayValidationResult(validationResult: ValidationResult) { - error.text = - when (validationResult) { - is NotValidated, - Valid, -> null - is Invalid -> validationResult.getSingleStringValidationMessage() - } - } - - override fun setReadOnly(isReadOnly: Boolean) { - slider.isEnabled = !isReadOnly - } + private fun displayValidationResult(validationResult: ValidationResult) = + when (validationResult) { + is NotValidated, + Valid, -> null + is Invalid -> validationResult.getSingleStringValidationMessage() + } } } diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/TimePickerViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/TimePickerViewHolderFactory.kt index ee872fefb0..c21d9c6abb 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/TimePickerViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/TimePickerViewHolderFactory.kt @@ -16,130 +16,106 @@ package com.google.android.fhir.datacapture.views.factories -import android.annotation.SuppressLint -import android.content.Context -import android.text.InputType -import android.text.format.DateFormat -import android.view.View -import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.lifecycleScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.extensions.getRequiredOrOptionalText +import com.google.android.fhir.datacapture.extensions.getValidationErrorMessage +import com.google.android.fhir.datacapture.extensions.itemMedia import com.google.android.fhir.datacapture.extensions.toLocalizedString -import com.google.android.fhir.datacapture.extensions.tryUnwrapContext -import com.google.android.fhir.datacapture.views.HeaderView 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.android.material.timepicker.MaterialTimePicker -import com.google.android.material.timepicker.MaterialTimePicker.INPUT_MODE_CLOCK -import com.google.android.material.timepicker.MaterialTimePicker.INPUT_MODE_KEYBOARD -import com.google.android.material.timepicker.TimeFormat +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.TimePickerItem import java.time.LocalTime import java.time.format.DateTimeFormatter +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.TimeType -object TimePickerViewHolderFactory : - QuestionnaireItemAndroidViewHolderFactory(R.layout.time_picker_view) { +object TimePickerViewHolderFactory : QuestionnaireItemComposeViewHolderFactory { override fun getQuestionnaireItemViewHolderDelegate() = - object : QuestionnaireItemAndroidViewHolderDelegate { - private val TAG = "time-picker" - private lateinit var context: AppCompatActivity - private lateinit var header: HeaderView - private lateinit var timeInputLayout: TextInputLayout - private lateinit var timeInputEditText: TextInputEditText - override lateinit var questionnaireViewItem: QuestionnaireViewItem + object : QuestionnaireItemComposeViewHolderDelegate { - override fun init(itemView: View) { - context = itemView.context.tryUnwrapContext()!! - header = itemView.findViewById(R.id.header) - timeInputLayout = itemView.findViewById(R.id.text_input_layout) - timeInputEditText = itemView.findViewById(R.id.text_input_edit_text) - timeInputEditText.inputType = InputType.TYPE_NULL - timeInputEditText.hint = itemView.context.getString(R.string.time) - - timeInputLayout.setEndIconOnClickListener { - // The application is wrapped in a ContextThemeWrapper in QuestionnaireFragment - // and again in TextInputEditText during layout inflation. As a result, it is - // necessary to access the base context twice to retrieve the application object - // from the view's context. - val context = itemView.context.tryUnwrapContext()!! - buildMaterialTimePicker(context, INPUT_MODE_CLOCK) - } - timeInputEditText.setOnClickListener { - buildMaterialTimePicker(itemView.context, INPUT_MODE_KEYBOARD) - } - } - - @SuppressLint("NewApi") // java.time APIs can be used due to desugaring - override fun bind(questionnaireViewItem: QuestionnaireViewItem) { - clearPreviousState() - header.bind(questionnaireViewItem) - timeInputLayout.helperText = getRequiredOrOptionalText(questionnaireViewItem, context) - - val questionnaireItemViewItemDateTimeAnswer = - questionnaireViewItem.answers.singleOrNull()?.valueTimeType?.localTime - - // If there is no set answer in the QuestionnaireItemViewItem, make the time field empty. - timeInputEditText.setText( - questionnaireItemViewItemDateTimeAnswer?.toLocalizedString(timeInputEditText.context) - ?: "", - ) - } + @Composable + override fun Content(questionnaireViewItem: QuestionnaireViewItem) { + val context = LocalContext.current + val validationMessage = + remember(questionnaireViewItem.validationResult) { + getValidationErrorMessage( + context, + questionnaireViewItem, + questionnaireViewItem.validationResult, + ) + } + val requiredOptionalText = + remember(questionnaireViewItem) { + getRequiredOrOptionalText(questionnaireViewItem, context) + } + val readOnly = + remember(questionnaireViewItem.questionnaireItem) { + questionnaireViewItem.questionnaireItem.readOnly + } + val questionnaireViewItemLocalTimeAnswer = + remember(questionnaireViewItem.answers) { + questionnaireViewItem.answers.singleOrNull()?.valueTimeType?.localTime + } + val initialTimeForSelection = + remember(questionnaireViewItemLocalTimeAnswer) { + questionnaireViewItemLocalTimeAnswer ?: LocalTime.now() + } + val questionnaireViewItemLocalTimeAnswerDisplay = + remember(questionnaireViewItemLocalTimeAnswer) { + questionnaireViewItemLocalTimeAnswer?.toLocalizedString(context) + } - override fun setReadOnly(isReadOnly: Boolean) { - // The system outside this delegate should only be able to mark it read only. Otherwise, it - // will change the state set by this delegate in bindView(). - if (isReadOnly) { - timeInputEditText.isEnabled = false - timeInputLayout.isEnabled = false - } - } + val coroutineScope = rememberCoroutineScope { Dispatchers.Main } - private fun buildMaterialTimePicker(context: Context, inputMode: Int) { - val selectedTime = - questionnaireViewItem.answers.singleOrNull()?.valueTimeType?.localTime ?: LocalTime.now() - val timeFormat = - if (DateFormat.is24HourFormat(context)) { - TimeFormat.CLOCK_24H - } else { - TimeFormat.CLOCK_12H - } - MaterialTimePicker.Builder() - .setTitleText(R.string.select_time) - .setHour(selectedTime.hour) - .setMinute(selectedTime.minute) - .setTimeFormat(timeFormat) - .setInputMode(inputMode) - .build() - .apply { - addOnPositiveButtonClickListener { - with(LocalTime.of(this.hour, this.minute, 0)) { - timeInputEditText.setText(this.toLocalizedString(context)) - setQuestionnaireItemViewItemAnswer(this) - timeInputEditText.clearFocus() - } - } + Column( + modifier = + Modifier.padding( + horizontal = dimensionResource(R.dimen.item_margin_horizontal), + vertical = dimensionResource(R.dimen.item_margin_vertical), + ), + ) { + Header(questionnaireViewItem) + questionnaireViewItem.questionnaireItem.itemMedia?.let { MediaItem(it) } + TimePickerItem( + modifier = Modifier.fillMaxWidth(), + initialStartTime = initialTimeForSelection, + timeSelectedDisplay = questionnaireViewItemLocalTimeAnswerDisplay, + enabled = !readOnly, + hint = stringResource(R.string.time), + supportingHelperText = + if (!validationMessage.isNullOrBlank()) validationMessage else requiredOptionalText, + isError = !validationMessage.isNullOrBlank(), + ) { + coroutineScope.launch { setQuestionnaireItemViewItemAnswer(questionnaireViewItem, it) } } - .show(context.tryUnwrapContext()!!.supportFragmentManager, TAG) + } } /** Set the answer in the [QuestionnaireResponse]. */ - private fun setQuestionnaireItemViewItemAnswer(localDateTime: LocalTime) = - context.lifecycleScope.launch { - questionnaireViewItem.setAnswer( - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent() - .setValue(TimeType(localDateTime.format(DateTimeFormatter.ISO_TIME))), - ) - } - - private fun clearPreviousState() { - timeInputEditText.isEnabled = true - timeInputLayout.isEnabled = true - } + private suspend fun setQuestionnaireItemViewItemAnswer( + questionnaireViewItem: QuestionnaireViewItem, + localDateTime: LocalTime, + ) = + questionnaireViewItem.setAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent() + .setValue(TimeType(localDateTime.format(DateTimeFormatter.ISO_TIME))), + ) } private val TimeType.localTime diff --git a/datacapture/src/main/res/drawable/ic_access_time.xml b/datacapture/src/main/res/drawable/ic_access_time.xml new file mode 100644 index 0000000000..2990b19421 --- /dev/null +++ b/datacapture/src/main/res/drawable/ic_access_time.xml @@ -0,0 +1,16 @@ + + + + diff --git a/datacapture/src/main/res/drawable/ic_keyboard.xml b/datacapture/src/main/res/drawable/ic_keyboard.xml new file mode 100644 index 0000000000..2b4c017e9a --- /dev/null +++ b/datacapture/src/main/res/drawable/ic_keyboard.xml @@ -0,0 +1,12 @@ + + + diff --git a/datacapture/src/main/res/layout/date_picker_view.xml b/datacapture/src/main/res/layout/date_picker_view.xml deleted file mode 100644 index d281b02376..0000000000 --- a/datacapture/src/main/res/layout/date_picker_view.xml +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - - - diff --git a/datacapture/src/main/res/layout/date_time_picker_view.xml b/datacapture/src/main/res/layout/date_time_picker_view.xml deleted file mode 100644 index 9d90386238..0000000000 --- a/datacapture/src/main/res/layout/date_time_picker_view.xml +++ /dev/null @@ -1,100 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - 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/drop_down_view.xml b/datacapture/src/main/res/layout/drop_down_view.xml deleted file mode 100644 index cfa218626f..0000000000 --- a/datacapture/src/main/res/layout/drop_down_view.xml +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/datacapture/src/main/res/layout/edit_text_auto_complete_view.xml b/datacapture/src/main/res/layout/edit_text_auto_complete_view.xml deleted file mode 100644 index 813d032317..0000000000 --- a/datacapture/src/main/res/layout/edit_text_auto_complete_view.xml +++ /dev/null @@ -1,82 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - 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 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/datacapture/src/main/res/layout/slider_view.xml b/datacapture/src/main/res/layout/slider_view.xml deleted file mode 100644 index 33cf8fe501..0000000000 --- a/datacapture/src/main/res/layout/slider_view.xml +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - - - - - - - diff --git a/datacapture/src/main/res/layout/time_picker_view.xml b/datacapture/src/main/res/layout/time_picker_view.xml deleted file mode 100644 index 2ad1cd5563..0000000000 --- a/datacapture/src/main/res/layout/time_picker_view.xml +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - - - diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/mapping/ResourceMapperTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/mapping/ResourceMapperTest.kt index a25e1e1817..e9320a84e7 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/mapping/ResourceMapperTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/mapping/ResourceMapperTest.kt @@ -25,7 +25,7 @@ import ca.uhn.fhir.parser.IParser import com.google.android.fhir.datacapture.extensions.CODE_SYSTEM_LAUNCH_CONTEXT import com.google.android.fhir.datacapture.extensions.EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT import com.google.android.fhir.datacapture.extensions.ITEM_INITIAL_EXPRESSION_URL -import com.google.android.fhir.datacapture.views.factories.localDate +import com.google.android.fhir.datacapture.extensions.localDate import com.google.android.fhir.knowledge.KnowledgeManager import com.google.common.truth.Truth.assertThat import java.math.BigDecimal diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/compose/DateVisualTransformationTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/compose/DateVisualTransformationTest.kt new file mode 100644 index 0000000000..1f48b89d39 --- /dev/null +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/compose/DateVisualTransformationTest.kt @@ -0,0 +1,117 @@ +/* + * 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.views.compose + +import androidx.compose.ui.text.AnnotatedString +import com.google.common.truth.Truth.assertThat +import org.junit.Assert.assertEquals +import org.junit.Test + +class DateVisualTransformationTest { + + private val transformation = + DateVisualTransformation(DateInputFormat("dd/MM/yyyy", delimiter = '/')) + private val noDelimiterTransformation = + DateVisualTransformation(DateInputFormat("ddMMyyyy", delimiter = '/')) + + @Test + fun `filter should return empty annotated string when text is empty`() { + val result = transformation.filter(AnnotatedString("")) + assertThat(result.text.text).isEmpty() + } + + @Test + fun `filter should return empty annotated string when text is empty for input format with no delimiter`() { + val result = noDelimiterTransformation.filter(AnnotatedString("")) + assertThat(result.text.text).isEmpty() + } + + @Test + fun `filter should format partial date with day`() { + val result = transformation.filter(AnnotatedString("12")) + assertThat(result.text.text).isEqualTo("12/") + } + + @Test + fun `filter should format partial date with day for input format with no delimiter`() { + val result = noDelimiterTransformation.filter(AnnotatedString("12")) + assertThat(result.text.text).isEqualTo("12") + } + + @Test + fun `filter should format partial date with day and month`() { + val result = transformation.filter(AnnotatedString("2812")) + assertThat(result.text.text).isEqualTo("28/12/") + } + + @Test + fun `filter should format partial date with day and month for input format with no delimiter`() { + val result = noDelimiterTransformation.filter(AnnotatedString("2812")) + assertThat(result.text.text).isEqualTo("2812") + } + + @Test + fun `filter should format full date`() { + val result = transformation.filter(AnnotatedString("28122023")) + assertThat(result.text.text).isEqualTo("28/12/2023") + } + + @Test + fun `filter should format full date for input format with no delimiter`() { + val result = noDelimiterTransformation.filter(AnnotatedString("28122023")) + assertThat(result.text.text).isEqualTo("28122023") + } + + @Test + fun `filter should truncate and format date longer than 8 characters`() { + val result = transformation.filter(AnnotatedString("311220231")) + assertThat(result.text.text).isEqualTo("31/12/2023") + } + + @Test + fun `filter should truncate and format date longer than 8 characters for input format with no delimiter`() { + val result = noDelimiterTransformation.filter(AnnotatedString("311220231")) + assertThat(result.text.text).isEqualTo("31122023") + } + + @Test + fun testOriginalToTransformedMapping() { + val originalText = AnnotatedString("28122023") + val transformedText = transformation.filter(originalText) + val offsetMapping = transformedText.offsetMapping + + assertEquals(0, offsetMapping.originalToTransformed(0)) + assertEquals(4, offsetMapping.originalToTransformed(3)) + assertEquals(5, offsetMapping.originalToTransformed(4)) + assertEquals(8, offsetMapping.originalToTransformed(6)) + assertEquals(10, offsetMapping.originalToTransformed(8)) + } + + @Test + fun testTransformedToOriginalMapping() { + val originalText = AnnotatedString("28122023") + val transformedText = transformation.filter(originalText) + val offsetMapping = transformedText.offsetMapping + + assertEquals(0, offsetMapping.transformedToOriginal(0)) + assertEquals(2, offsetMapping.transformedToOriginal(3)) + assertEquals(3, offsetMapping.transformedToOriginal(4)) + assertEquals(5, offsetMapping.transformedToOriginal(7)) + assertEquals(6, offsetMapping.transformedToOriginal(8)) + assertEquals(8, offsetMapping.transformedToOriginal(10)) + } +} diff --git a/engine/benchmarks/app/src/main/java/com/google/android/fhir/engine/benchmarks/app/ui/DetailScaffold.kt b/engine/benchmarks/app/src/main/java/com/google/android/fhir/engine/benchmarks/app/ui/DetailScaffold.kt index d3904825fa..1c5148047e 100644 --- a/engine/benchmarks/app/src/main/java/com/google/android/fhir/engine/benchmarks/app/ui/DetailScaffold.kt +++ b/engine/benchmarks/app/src/main/java/com/google/android/fhir/engine/benchmarks/app/ui/DetailScaffold.kt @@ -19,9 +19,6 @@ package com.google.android.fhir.engine.benchmarks.app.ui import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -33,10 +30,12 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.google.android.fhir.engine.benchmarks.app.R @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -52,8 +51,8 @@ fun DetailScaffold( navigationIcon = { IconButton(onClick = navigateToHome) { Icon( - imageVector = Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = "Localized description", + painterResource(R.drawable.arrow_back_24px), + contentDescription = "Back", ) } }, diff --git a/engine/benchmarks/app/src/main/res/drawable/arrow_back_24px.xml b/engine/benchmarks/app/src/main/res/drawable/arrow_back_24px.xml new file mode 100644 index 0000000000..c51a167b58 --- /dev/null +++ b/engine/benchmarks/app/src/main/res/drawable/arrow_back_24px.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fc67d7a693..31d8ffab2f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,12 +6,12 @@ android-fhir-common = "0.1.0-alpha05" android-fhir-engine = "0.1.0-beta05" android-fhir-knowledge = "0.1.0-beta01" androidx-activity = "1.7.2" -androidx-activity-compose = "1.10.1" +androidx-activity-compose = "1.11.0" androidx-appcompat = "1.6.1" androidx-arch-core = "2.2.0" androidx-benchmark = "1.4.0-rc01" androidx-benchmark-macro = "1.4.0-rc01" -androidx-compose-bom = "2025.07.00" +androidx-compose-bom = "2025.10.01" androidx-constraintlayout = "2.1.4" androidx-core = "1.10.1" androidx-datastore = "1.0.0" @@ -19,7 +19,7 @@ androidx-espresso = "3.5.1" androidx-fragment = "1.6.0" androidx-lifecycle = "2.8.7" androidx-navigation = "2.6.0" -androidx-navigation-compose = "2.8.9" +androidx-navigation-compose = "2.9.5" androidx-profilerinstaller = "1.4.1" androidx-recyclerview = "1.4.0" androidx-room = "2.7.1"