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 8b7c2e27db..07f178aab1 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,41 +19,53 @@ 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.junit4.createAndroidComposeRule +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.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions import androidx.test.espresso.assertion.ViewAssertions import androidx.test.espresso.assertion.ViewAssertions.doesNotExist -import androidx.test.espresso.matcher.RootMatchers import androidx.test.espresso.matcher.ViewMatchers 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 @@ -68,7 +80,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 @@ -77,7 +88,11 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class QuestionnaireUiEspressoTest { - @get:Rule(order = 9) val composeTestRule = createAndroidComposeRule() + @get:Rule + val activityScenarioRule: ActivityScenarioRule = + ActivityScenarioRule(TestActivity::class.java) + + @get:Rule val composeTestRule = createEmptyComposeRule() private lateinit var parent: FrameLayout private val parser: IParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() @@ -85,14 +100,14 @@ class QuestionnaireUiEspressoTest { @Before fun setup() { - composeTestRule.activityRule.scenario.onActivity { activity -> parent = FrameLayout(activity) } + activityScenarioRule.scenario.onActivity { activity -> parent = FrameLayout(activity) } } @Test fun shouldDisplayReviewButtonWhenNoMorePagesToDisplay() { buildFragmentFromQuestionnaire("/paginated_questionnaire_with_dependent_answer.json", true) - onView(withId(com.google.android.fhir.datacapture.R.id.review_mode_button)) + onView(withId(R.id.review_mode_button)) .check( ViewAssertions.matches( ViewMatchers.withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE), @@ -100,13 +115,13 @@ class QuestionnaireUiEspressoTest { ) clickOnText("Yes") - onView(withId(com.google.android.fhir.datacapture.R.id.review_mode_button)) + onView(withId(R.id.review_mode_button)) .check( ViewAssertions.matches(ViewMatchers.withEffectiveVisibility(ViewMatchers.Visibility.GONE)), ) clickOnText("No") - onView(withId(com.google.android.fhir.datacapture.R.id.review_mode_button)) + onView(withId(R.id.review_mode_button)) .check( ViewAssertions.matches( ViewMatchers.withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE), @@ -120,7 +135,7 @@ class QuestionnaireUiEspressoTest { clickOnText("Next") - onView(withId(com.google.android.fhir.datacapture.R.id.pagination_next_button)) + onView(withId(R.id.pagination_next_button)) .check( ViewAssertions.matches(ViewMatchers.withEffectiveVisibility(ViewMatchers.Visibility.GONE)), ) @@ -130,7 +145,7 @@ class QuestionnaireUiEspressoTest { fun shouldDisplayNextButtonIfEnabled() { buildFragmentFromQuestionnaire("/layout_paginated.json", true) - onView(withId(com.google.android.fhir.datacapture.R.id.pagination_next_button)) + onView(withId(R.id.pagination_next_button)) .check( ViewAssertions.matches( ViewMatchers.withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE), @@ -217,62 +232,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(com.google.android.fhir.datacapture.R.id.date_input_edit_text)) - .perform(ViewActions.click()) - .perform(ViewActions.typeTextIntoFocusedView("0105")) + composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextReplacement("0105") - onView(withId(com.google.android.fhir.datacapture.R.id.date_input_layout)).check { view, _ -> - val actualError = (view as TextInputLayout).error - assertThat(actualError).isEqualTo("Date format needs to be mm/dd/yyyy (e.g. 01/31/2023)") - } - onView(withId(com.google.android.fhir.datacapture.R.id.time_input_layout)).check { view, _ -> - assertThat(view.isEnabled).isFalse() - } + 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(com.google.android.fhir.datacapture.R.id.date_input_edit_text)) - .perform(ViewActions.click()) - .perform(ViewActions.typeTextIntoFocusedView("01052005")) - - onView(withId(com.google.android.fhir.datacapture.R.id.date_input_layout)).check { view, _ -> - val actualError = (view as TextInputLayout).error - assertThat(actualError).isEqualTo(null) - } + composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextReplacement("01052005") - onView(withId(com.google.android.fhir.datacapture.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(com.google.android.fhir.datacapture.R.id.date_input_edit_text)) - .perform(ViewActions.click()) - .perform(ViewActions.typeTextIntoFocusedView("01052005")) + composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextReplacement("01052005") - onView(withId(com.google.android.fhir.datacapture.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 @@ -280,28 +303,25 @@ class QuestionnaireUiEspressoTest { buildFragmentFromQuestionnaire("/component_date_picker.json") // Add month and day. No need to add slashes as they are added automatically - onView(withId(com.google.android.fhir.datacapture.R.id.text_input_edit_text)) - .perform(ViewActions.click()) - .perform(ViewActions.typeTextIntoFocusedView("0105")) - - onView(withId(com.google.android.fhir.datacapture.R.id.text_input_layout)).check { view, _ -> - val actualError = (view as TextInputLayout).error - assertThat(actualError).isEqualTo("Date format needs to be mm/dd/yyyy (e.g. 01/31/2023)") - } + 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(com.google.android.fhir.datacapture.R.id.text_input_edit_text)) - .perform(ViewActions.click()) - .perform(ViewActions.typeTextIntoFocusedView("01052005")) - - onView(withId(com.google.android.fhir.datacapture.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 @@ -333,12 +353,14 @@ class QuestionnaireUiEspressoTest { } buildFragmentFromQuestionnaire(questionnaire) - onView(withId(com.google.android.fhir.datacapture.R.id.text_input_layout)) - .perform(clickIcon(true)) - onView(CoreMatchers.allOf(withText("OK"))) - .inRoot(RootMatchers.isDialog()) - .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) - .perform(ViewActions.click()) + 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 @@ -381,12 +403,14 @@ class QuestionnaireUiEspressoTest { } buildFragmentFromQuestionnaire(questionnaire) - onView(withId(com.google.android.fhir.datacapture.R.id.text_input_layout)) - .perform(clickIcon(true)) - onView(CoreMatchers.allOf(withText("OK"))) - .inRoot(RootMatchers.isDialog()) - .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) - .perform(ViewActions.click()) + 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 @@ -429,12 +453,14 @@ class QuestionnaireUiEspressoTest { } buildFragmentFromQuestionnaire(questionnaire) - onView(withId(com.google.android.fhir.datacapture.R.id.text_input_layout)) - .perform(clickIcon(true)) - onView(CoreMatchers.allOf(withText("OK"))) - .inRoot(RootMatchers.isDialog()) - .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) - .perform(ViewActions.click()) + 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 @@ -454,7 +480,7 @@ class QuestionnaireUiEspressoTest { } @Test - fun datePicker_shouldThrowException_whenMinValueRangeIsGreaterThanMaxValueRange() { + fun datePicker_shouldProhibitInputWithErrorMessage_whenMinValueRangeIsGreaterThanMaxValueRange() { val questionnaire = Questionnaire().apply { id = "a-questionnaire" @@ -478,51 +504,50 @@ 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(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 fun displayItems_shouldGetEnabled_withAnswerChoice() { buildFragmentFromQuestionnaire("/questionnaire_with_enabled_display_items.json") - onView(withId(com.google.android.fhir.datacapture.R.id.hint)).check { view, _ -> + onView(withId(R.id.hint)).check { view, _ -> val hintVisibility = (view as TextView).visibility assertThat(hintVisibility).isEqualTo(View.GONE) } - onView(withId(com.google.android.fhir.datacapture.R.id.yes_radio_button)) - .perform(ViewActions.click()) + onView(withId(R.id.yes_radio_button)).perform(ViewActions.click()) - onView(withId(com.google.android.fhir.datacapture.R.id.hint)).check { view, _ -> + onView(withId(R.id.hint)).check { view, _ -> val hintVisibility = (view as TextView).visibility val hintText = view.text.toString() assertThat(hintVisibility).isEqualTo(View.VISIBLE) assertThat(hintText).isEqualTo("Text when yes is selected") } - onView(withId(com.google.android.fhir.datacapture.R.id.no_radio_button)) - .perform(ViewActions.click()) + onView(withId(R.id.no_radio_button)).perform(ViewActions.click()) - onView(withId(com.google.android.fhir.datacapture.R.id.hint)).check { view, _ -> + onView(withId(R.id.hint)).check { view, _ -> val hintVisibility = (view as TextView).visibility val hintText = view.text.toString() assertThat(hintVisibility).isEqualTo(View.VISIBLE) assertThat(hintText).isEqualTo("Text when no is selected") } - onView(withId(com.google.android.fhir.datacapture.R.id.no_radio_button)) - .perform(ViewActions.click()) + onView(withId(R.id.no_radio_button)).perform(ViewActions.click()) - onView(withId(com.google.android.fhir.datacapture.R.id.hint)).check { view, _ -> + onView(withId(R.id.hint)).check { view, _ -> val hintVisibility = (view as TextView).visibility assertThat(hintVisibility).isEqualTo(View.GONE) } @@ -533,7 +558,7 @@ class QuestionnaireUiEspressoTest { buildFragmentFromQuestionnaire("/questionnaire_with_dynamic_question_text.json") onView(CoreMatchers.allOf(withText("Option Date"))).check { view, _ -> - assertThat(view.id).isEqualTo(com.google.android.fhir.datacapture.R.id.question) + assertThat(view.id).isEqualTo(R.id.question) } onView(CoreMatchers.allOf(withText("Provide \"First Option\" Date"))).check { view, _ -> @@ -547,118 +572,108 @@ class QuestionnaireUiEspressoTest { } onView(CoreMatchers.allOf(withText("Provide \"First Option\" Date"))).check { view, _ -> - assertThat(view.id).isEqualTo(com.google.android.fhir.datacapture.R.id.question) + assertThat(view.id).isEqualTo(R.id.question) } } @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(com.google.android.fhir.datacapture.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(com.google.android.fhir.datacapture.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 fun progressBar_shouldBeVisible_withSinglePageQuestionnaire() { buildFragmentFromQuestionnaire("/text_questionnaire_integer.json") - onView(withId(com.google.android.fhir.datacapture.R.id.questionnaire_progress_indicator)) - .check { view, _ -> - val linearProgressIndicator = (view as LinearProgressIndicator) - assertThat(linearProgressIndicator.visibility).isEqualTo(View.VISIBLE) - assertThat(linearProgressIndicator.progress).isEqualTo(100) - } + onView(withId(R.id.questionnaire_progress_indicator)).check { view, _ -> + val linearProgressIndicator = (view as LinearProgressIndicator) + assertThat(linearProgressIndicator.visibility).isEqualTo(View.VISIBLE) + assertThat(linearProgressIndicator.progress).isEqualTo(100) + } } @Test fun progressBar_shouldBeVisible_withPaginatedQuestionnaire() { buildFragmentFromQuestionnaire("/layout_paginated.json") - onView(withId(com.google.android.fhir.datacapture.R.id.questionnaire_progress_indicator)) - .check { view, _ -> - val linearProgressIndicator = (view as LinearProgressIndicator) - assertThat(linearProgressIndicator.visibility).isEqualTo(View.VISIBLE) - assertThat(linearProgressIndicator.progress).isEqualTo(50) - } + onView(withId(R.id.questionnaire_progress_indicator)).check { view, _ -> + val linearProgressIndicator = (view as LinearProgressIndicator) + assertThat(linearProgressIndicator.visibility).isEqualTo(View.VISIBLE) + assertThat(linearProgressIndicator.progress).isEqualTo(50) + } } @Test fun progressBar_shouldProgress_onPaginationNext() { buildFragmentFromQuestionnaire("/layout_paginated.json") - onView(withId(com.google.android.fhir.datacapture.R.id.pagination_next_button)) - .perform(ViewActions.click()) + onView(withId(R.id.pagination_next_button)).perform(ViewActions.click()) - onView(withId(com.google.android.fhir.datacapture.R.id.questionnaire_progress_indicator)) - .check { view, _ -> - val linearProgressIndicator = (view as LinearProgressIndicator) - assertThat(linearProgressIndicator.progress).isEqualTo(100) - } + onView(withId(R.id.questionnaire_progress_indicator)).check { view, _ -> + val linearProgressIndicator = (view as LinearProgressIndicator) + assertThat(linearProgressIndicator.progress).isEqualTo(100) + } } @Test fun progressBar_shouldBeGone_whenNavigatedToReviewScreen() { buildFragmentFromQuestionnaire("/text_questionnaire_integer.json", isReviewMode = true) - onView(withId(com.google.android.fhir.datacapture.R.id.review_mode_button)) - .perform(ViewActions.click()) + onView(withId(R.id.review_mode_button)).perform(ViewActions.click()) - onView(withId(com.google.android.fhir.datacapture.R.id.questionnaire_progress_indicator)) - .check { view, _ -> - val linearProgressIndicator = (view as LinearProgressIndicator) - assertThat(linearProgressIndicator.visibility).isEqualTo(View.GONE) - } + onView(withId(R.id.questionnaire_progress_indicator)).check { view, _ -> + val linearProgressIndicator = (view as LinearProgressIndicator) + assertThat(linearProgressIndicator.visibility).isEqualTo(View.GONE) + } } @Test fun progressBar_shouldBeVisible_whenNavigatedToEditScreenFromReview() { buildFragmentFromQuestionnaire("/text_questionnaire_integer.json", isReviewMode = true) - onView(withId(com.google.android.fhir.datacapture.R.id.review_mode_button)) - .perform(ViewActions.click()) + onView(withId(R.id.review_mode_button)).perform(ViewActions.click()) - onView(withId(com.google.android.fhir.datacapture.R.id.review_mode_edit_button)) - .perform(ViewActions.click()) + onView(withId(R.id.review_mode_edit_button)).perform(ViewActions.click()) - onView(withId(com.google.android.fhir.datacapture.R.id.questionnaire_progress_indicator)) - .check { view, _ -> - val linearProgressIndicator = (view as LinearProgressIndicator) - assertThat(linearProgressIndicator.visibility).isEqualTo(View.VISIBLE) - } + onView(withId(R.id.questionnaire_progress_indicator)).check { view, _ -> + val linearProgressIndicator = (view as LinearProgressIndicator) + assertThat(linearProgressIndicator.visibility).isEqualTo(View.VISIBLE) + } } @Test fun test_add_item_button_does_not_exist_for_non_repeated_groups() { buildFragmentFromQuestionnaire("/component_non_repeated_group.json") - onView(withId(com.google.android.fhir.datacapture.R.id.add_item_to_repeated_group)) - .check(doesNotExist()) + onView(withId(R.id.add_item_to_repeated_group)).check(doesNotExist()) } @Test fun test_repeated_group_is_added() { buildFragmentFromQuestionnaire("/component_repeated_group.json") - onView(withId(com.google.android.fhir.datacapture.R.id.add_item_to_repeated_group)) - .perform(ViewActions.click()) + onView(withId(R.id.add_item_to_repeated_group)).perform(ViewActions.click()) composeTestRule .onNodeWithTag(QuestionnaireFragment.QUESTIONNAIRE_EDIT_LIST) .assertExists() .assertIsDisplayed() - onView(withId(com.google.android.fhir.datacapture.R.id.repeated_group_instance_header_title)) + onView(withId(R.id.repeated_group_instance_header_title)) .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) - onView(withText(com.google.android.fhir.datacapture.R.string.delete)) - .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) + onView(withText(R.string.delete)).check(ViewAssertions.matches(ViewMatchers.isDisplayed())) } @Test @@ -666,12 +681,12 @@ class QuestionnaireUiEspressoTest { buildFragmentFromQuestionnaire("/component_multiple_repeated_group.json") onView(allOf(withText("Add Repeated Group"))).perform(ViewActions.click()) - onView(allOf(withText(com.google.android.fhir.datacapture.R.string.delete))) + onView(allOf(withText(R.string.delete))) .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) onView( allOf( - withId(com.google.android.fhir.datacapture.R.id.repeated_group_instance_header_title), + withId(R.id.repeated_group_instance_header_title), ), ) .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) @@ -689,14 +704,12 @@ class QuestionnaireUiEspressoTest { .assertExists() .assertIsDisplayed() - onView(withId(com.google.android.fhir.datacapture.R.id.repeated_group_instance_header_title)) + onView(withId(R.id.repeated_group_instance_header_title)) .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) - onView(withText(com.google.android.fhir.datacapture.R.string.delete)) - .perform(ViewActions.click()) + onView(withText(R.string.delete)).perform(ViewActions.click()) - onView(withText(com.google.android.fhir.datacapture.R.id.repeated_group_instance_header_title)) - .check(doesNotExist()) + onView(withText(R.id.repeated_group_instance_header_title)).check(doesNotExist()) } private fun buildFragmentFromQuestionnaire( @@ -714,10 +727,10 @@ class QuestionnaireUiEspressoTest { responseFileName?.let { builder.setQuestionnaireResponse(readFileFromAssets(it)) } return builder.build().also { fragment -> - composeTestRule.activityRule.scenario.onActivity { activity -> + 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) } } } @@ -732,10 +745,10 @@ class QuestionnaireUiEspressoTest { .setQuestionnaire(parser.encodeResourceToString(questionnaire)) .showReviewPageBeforeSubmit(isReviewMode) .build() - composeTestRule.activityRule.scenario.onActivity { activity -> + 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) } } } @@ -745,10 +758,11 @@ class QuestionnaireUiEspressoTest { private suspend fun getQuestionnaireResponse(): QuestionnaireResponse { var testQuestionnaireFragment: QuestionnaireFragment? = null - composeTestRule.activityRule.scenario.onActivity { activity -> + 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/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/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/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/MoreTypes.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt index d01364555c..2e55e44687 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/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/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/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/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/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 4da7df1997..6e821e1543 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)) + } +}