From 7eb22d965f2671ddc666a8e719d504489be8e091 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=E2=89=A1ZRS?= <12814349+LZRS@users.noreply.github.com> Date: Mon, 8 Sep 2025 11:02:39 +0300 Subject: [PATCH 01/10] Migrate DatePickerViewFactory to compose --- .../test/QuestionnaireUiEspressoTest.kt | 274 +++++------ .../views}/DatePickerViewHolderFactoryTest.kt | 331 ++++++++------ .../datacapture/extensions/MoreLocalDates.kt | 66 ++- .../fhir/datacapture/extensions/MoreTypes.kt | 2 - .../views/compose/DatePickerItem.kt | 201 +++++++++ .../views/compose/DateVisualTransformation.kt | 78 ++++ .../datacapture/views/compose/ErrorText.kt | 40 ++ .../factories/DatePickerViewHolderFactory.kt | 424 +++++++----------- .../DateTimePickerViewHolderFactory.kt | 64 +-- .../src/main/res/layout/date_picker_view.xml | 57 --- .../datacapture/mapping/ResourceMapperTest.kt | 2 +- .../compose/DateVisualTransformationTest.kt | 117 +++++ 12 files changed, 1048 insertions(+), 608 deletions(-) rename datacapture/src/{test/java/com/google/android/fhir/datacapture/views/factories => androidTest/java/com/google/android/fhir/datacapture/test/views}/DatePickerViewHolderFactoryTest.kt (68%) create mode 100644 datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/DatePickerItem.kt create mode 100644 datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/DateVisualTransformation.kt create mode 100644 datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/ErrorText.kt delete mode 100644 datacapture/src/main/res/layout/date_picker_view.xml create mode 100644 datacapture/src/test/java/com/google/android/fhir/datacapture/views/compose/DateVisualTransformationTest.kt 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..ae74c419f5 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,12 +19,20 @@ package com.google.android.fhir.datacapture.test import android.view.View import android.widget.FrameLayout import android.widget.TextView +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.junit4.createAndroidComposeRule +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.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.fragment.app.commitNow import androidx.test.espresso.Espresso.onView @@ -32,27 +40,29 @@ 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.contrib.RecyclerViewActions 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.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.clickIcon 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.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 @@ -77,7 +87,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 +99,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 +114,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 +134,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 +144,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,35 +231,31 @@ 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)) + onView(withId(R.id.date_input_edit_text)) .perform(ViewActions.click()) .perform(ViewActions.typeTextIntoFocusedView("0105")) - onView(withId(com.google.android.fhir.datacapture.R.id.date_input_layout)).check { view, _ -> + onView(withId(R.id.date_input_layout)).check { view, _ -> val actualError = (view as TextInputLayout).error assertThat(actualError).isEqualTo("Date format needs to be mm/dd/yyyy (e.g. 01/31/2023)") } - onView(withId(com.google.android.fhir.datacapture.R.id.time_input_layout)).check { view, _ -> - assertThat(view.isEnabled).isFalse() - } + onView(withId(R.id.time_input_layout)).check { view, _ -> assertThat(view.isEnabled).isFalse() } } @Test fun dateTimePicker_shouldEnableTimePickerWithCorrectDate_butNotSaveInQuestionnaireResponse() { buildFragmentFromQuestionnaire("/component_date_time_picker.json") - onView(withId(com.google.android.fhir.datacapture.R.id.date_input_edit_text)) + onView(withId(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, _ -> + onView(withId(R.id.date_input_layout)).check { view, _ -> val actualError = (view as TextInputLayout).error assertThat(actualError).isEqualTo(null) } - onView(withId(com.google.android.fhir.datacapture.R.id.time_input_layout)).check { view, _ -> - assertThat(view.isEnabled).isTrue() - } + onView(withId(R.id.time_input_layout)).check { view, _ -> assertThat(view.isEnabled).isTrue() } runBlocking { assertThat(getQuestionnaireResponse().item.size).isEqualTo(1) @@ -257,12 +267,11 @@ class QuestionnaireUiEspressoTest { fun dateTimePicker_shouldSetAnswerWhenDateAndTimeAreFilled() { buildFragmentFromQuestionnaire("/component_date_time_picker.json") - onView(withId(com.google.android.fhir.datacapture.R.id.date_input_edit_text)) + onView(withId(R.id.date_input_edit_text)) .perform(ViewActions.click()) .perform(ViewActions.typeTextIntoFocusedView("01052005")) - onView(withId(com.google.android.fhir.datacapture.R.id.time_input_layout)) - .perform(clickIcon(true)) + onView(withId(R.id.time_input_layout)).perform(clickIcon(true)) clickOnText("AM") clickOnText("6") clickOnText("10") @@ -280,28 +289,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 +339,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 +389,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 +439,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 @@ -480,12 +492,14 @@ 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()) + composeTestRule + .onNodeWithContentDescription(context.getString(R.string.select_date)) + .performClick() + composeTestRule + .onNode(hasText("OK") and hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performClick() + composeTestRule.waitForIdle() // Synchronize } assertThat(exception.message).isEqualTo("minValue cannot be greater than maxValue") } @@ -494,35 +508,32 @@ class QuestionnaireUiEspressoTest { 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 +544,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,106 +558,98 @@ 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)) + onView(withId(R.id.add_item_to_repeated_group)) .perform(ViewActions.click()) composeTestRule @@ -654,10 +657,10 @@ 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)) + onView(withText(R.string.delete)) .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) } @@ -666,12 +669,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,13 +692,13 @@ 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)) + onView(withText(R.string.delete)) .perform(ViewActions.click()) - onView(withText(com.google.android.fhir.datacapture.R.id.repeated_group_instance_header_title)) + onView(withText(R.id.repeated_group_instance_header_title)) .check(doesNotExist()) } @@ -714,10 +717,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 +735,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 +748,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..c2297a9e4b 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,71 @@ * limitations under the License. */ -package com.google.android.fhir.datacapture.views.factories +package com.google.android.fhir.datacapture.test.views -import android.view.View import android.widget.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.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 +91,9 @@ class DatePickerViewHolderFactoryTest { ), ) + // Synchronize + composeTestRule.waitForIdle() + assertThat(viewHolder.itemView.findViewById(R.id.question).text.toString()) .isEqualTo("Question?") } @@ -77,11 +109,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 +128,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 +148,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 +164,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 +184,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 +204,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 +222,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 +234,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 +245,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 +255,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 +270,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 +299,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 +331,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 +357,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 +377,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 +387,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 +405,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 +426,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 +457,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 +515,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 +531,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 +548,11 @@ class DatePickerViewHolderFactoryTest { ), ) - assertThat(viewHolder.dateInputView.isEnabled).isFalse() + composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).assertIsNotEnabled() } @Test - fun `bind multiple times with different QuestionnaireItemViewItem should show proper date`() { + fun bindMultipleTimesWithDifferentQuestionnaireItemViewItemShouldShowProperDate() { setLocale(Locale.US) viewHolder.bind( @@ -490,7 +567,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 +583,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 +595,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 +613,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 +631,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 +649,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 +667,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 +685,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 +701,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 +721,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 +742,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 +761,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 +776,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 +791,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 +806,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/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..e1c40d5529 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,11 +17,13 @@ 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 @@ -29,6 +31,10 @@ import java.time.format.DateTimeFormatterBuilder 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 @@ -129,3 +135,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..4319fefab4 --- /dev/null +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/DatePickerItem.kt @@ -0,0 +1,201 @@ +/* + * 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 && !helperText.isNullOrBlank()) 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 (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/factories/DatePickerViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DatePickerViewHolderFactory.kt index 304c9b7e63..c923196667 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,156 +16,164 @@ 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.itemMedia +import com.google.android.fhir.datacapture.extensions.localDate import com.google.android.fhir.datacapture.extensions.parseDate -import com.google.android.fhir.datacapture.extensions.tryUnwrapContext +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 = - 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 = + 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 = questionnaireViewItem.answers.singleOrNull()?.valueDateType?.localDate - - val dateStringToDisplay = - questionnaireItemViewItemDateAnswer?.format(canonicalizedDatePattern) - ?: questionnaireViewItem.draftAnswer as? String - - if (textInputEditText.text.toString() != dateStringToDisplay) { - textInputEditText.setText(dateStringToDisplay) - } - - // 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)), + 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) + } + val validationMessage = + remember(draftAnswer) { + // If the draft answer is set, this means the user has yet to type a parseable answer, + // so we display an error. + if (!draftAnswer.isNullOrEmpty()) { + getValidationErrorMessage( + context, + questionnaireViewItem, + Invalid( + listOf(invalidDateErrorText(context, canonicalizedDatePattern)), + ), + ) + } else { + getValidationErrorMessage( + context, + questionnaireViewItem, + questionnaireViewItem.validationResult, + ) + } + } + val selectableDates = + remember(questionnaireViewItem) { { getSelectableDates(questionnaireViewItem) } } + + 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, + parseStringToLocalDate = { str, pattern -> getLocalDate(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, + ): SelectableDates { val min = (questionnaireViewItem.minAnswerValue as? DateType)?.value?.time val max = (questionnaireViewItem.maxAnswerValue as? DateType)?.value?.time @@ -173,82 +181,44 @@ internal object DatePickerViewHolderFactory : throw IllegalArgumentException("minValue cannot be greater than maxValue") } - 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 + return selectableDates(min, max) } /** 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 suspend fun parseDateOnTextChanged( + questionnaireViewItem: QuestionnaireViewItem, + dateToDisplay: String, + pattern: String, + ) { + val localDate = getLocalDate(dateToDisplay, pattern) + if (localDate != null) { + setQuestionnaireItemViewItemAnswer(questionnaireViewItem, localDate) + } else { + 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 fun getLocalDate(dateToDisplay: String, pattern: String): LocalDate? { + return try { + parseDate(dateToDisplay, pattern) + } catch (_: ParseException) { + null + } catch (_: DateTimeParseException) { + null } } } @@ -257,71 +227,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 +235,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..a4e8e535e7 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 @@ -33,6 +33,9 @@ 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.localDate +import com.google.android.fhir.datacapture.extensions.localDateTime +import com.google.android.fhir.datacapture.extensions.localTime import com.google.android.fhir.datacapture.extensions.parseDate import com.google.android.fhir.datacapture.extensions.toLocalizedString import com.google.android.fhir.datacapture.extensions.tryUnwrapContext @@ -322,29 +325,38 @@ internal object DateTimePickerViewHolderFactory : 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, - ) +/** + * 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()) + } + } +} 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/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)) + } +} From d2be6b98a7b70972932ecdf7e791b444ed5f5f78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=E2=89=A1ZRS?= <12814349+LZRS@users.noreply.github.com> Date: Wed, 24 Sep 2025 03:19:33 +0300 Subject: [PATCH 02/10] Prohibit datePicker input when minValue/maxValue conflict Instead of throwing an IllegalArgumentException --- .../test/QuestionnaireUiEspressoTest.kt | 28 +++++------ .../views/DatePickerViewHolderFactoryTest.kt | 4 ++ .../views/compose/DatePickerItem.kt | 8 ++-- .../factories/DatePickerViewHolderFactory.kt | 46 +++++++++++-------- 4 files changed, 48 insertions(+), 38 deletions(-) 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 ae74c419f5..2862735253 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 @@ -23,6 +23,7 @@ import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.hasAnyAncestor @@ -78,7 +79,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 @@ -466,7 +466,7 @@ class QuestionnaireUiEspressoTest { } @Test - fun datePicker_shouldThrowException_whenMinValueRangeIsGreaterThanMaxValueRange() { + fun datePicker_shouldProhibitInputWithErrorMessage_whenMinValueRangeIsGreaterThanMaxValueRange() { val questionnaire = Questionnaire().apply { id = "a-questionnaire" @@ -490,18 +490,18 @@ class QuestionnaireUiEspressoTest { } buildFragmentFromQuestionnaire(questionnaire) - val exception = - Assert.assertThrows(IllegalArgumentException::class.java) { - composeTestRule - .onNodeWithContentDescription(context.getString(R.string.select_date)) - .performClick() - composeTestRule - .onNode(hasText("OK") and hasAnyAncestor(isDialog())) - .assertIsDisplayed() - .performClick() - composeTestRule.waitForIdle() // Synchronize - } - 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 diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DatePickerViewHolderFactoryTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DatePickerViewHolderFactoryTest.kt index c2297a9e4b..9a3cd25245 100644 --- a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DatePickerViewHolderFactoryTest.kt +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DatePickerViewHolderFactoryTest.kt @@ -27,6 +27,7 @@ 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 @@ -549,6 +550,9 @@ class DatePickerViewHolderFactoryTest { ) composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).assertIsNotEnabled() + composeTestRule + .onNodeWithContentDescription(viewHolder.itemView.context.getString(R.string.select_date)) + .assertIsNotEnabled() } @Test 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 index 4319fefab4..8b246c218e 100644 --- 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 @@ -63,7 +63,7 @@ internal fun DatePickerItem( isError: Boolean, enabled: Boolean, dateInputFormat: DateInputFormat, - selectableDates: () -> SelectableDates, + selectableDates: SelectableDates?, parseStringToLocalDate: (String, DateFormatPattern) -> LocalDate?, onDateInputEntry: (DateInput) -> Unit, ) { @@ -136,7 +136,7 @@ internal fun DatePickerItem( visualTransformation = DateVisualTransformation(dateInputFormat), ) - if (showDatePickerModal) { + if (selectableDates != null && showDatePickerModal) { DatePickerModal( initialSelectedDateMillis, selectableDates, @@ -159,12 +159,12 @@ internal fun DatePickerItem( @Composable internal fun DatePickerModal( initialSelectedDateMillis: Long?, - selectableDates: () -> SelectableDates, + selectableDates: SelectableDates, onDateSelected: (Long?) -> Unit, onDismiss: () -> Unit, ) { val datePickerState = - rememberDatePickerState(initialSelectedDateMillis, selectableDates = selectableDates()) + rememberDatePickerState(initialSelectedDateMillis, selectableDates = selectableDates) val datePickerSelectedDateMillis = remember(initialSelectedDateMillis) { initialSelectedDateMillis } val confirmEnabled by remember { derivedStateOf { datePickerState.selectedDateMillis != null } } 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 c923196667..d32c8cc34f 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 @@ -106,28 +106,34 @@ internal object DatePickerViewHolderFactory : QuestionnaireItemComposeViewHolder ?.let { DateInput(it, questionnaireItemAnswerLocalDate) } ?: DateInput(display = draftAnswer ?: "", null) } + + val selectableDatesResult = + remember(questionnaireViewItem) { getSelectableDates(questionnaireViewItem) } + + val selectableDates = remember(selectableDatesResult) { selectableDatesResult.getOrNull() } + + val prohibitInput = remember(selectableDatesResult) { selectableDatesResult.isFailure } + val validationMessage = - remember(draftAnswer) { - // If the draft answer is set, this means the user has yet to type a parseable answer, - // so we display an error. - if (!draftAnswer.isNullOrEmpty()) { - getValidationErrorMessage( - context, - questionnaireViewItem, - Invalid( - listOf(invalidDateErrorText(context, canonicalizedDatePattern)), - ), - ) + 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, - questionnaireViewItem.validationResult, + if (!draftAnswer.isNullOrEmpty()) { + Invalid( + listOf(invalidDateErrorText(context, canonicalizedDatePattern)), + ) + } else { + questionnaireViewItem.validationResult + }, ) } } - val selectableDates = - remember(questionnaireViewItem) { { getSelectableDates(questionnaireViewItem) } } Column( modifier = @@ -149,7 +155,7 @@ internal object DatePickerViewHolderFactory : QuestionnaireItemComposeViewHolder helperText = validationMessage.takeIf { !it.isNullOrBlank() } ?: getRequiredOrOptionalText(questionnaireViewItem, context), isError = !validationMessage.isNullOrBlank(), - enabled = !questionnaireViewItem.questionnaireItem.readOnly, + enabled = !(questionnaireViewItem.questionnaireItem.readOnly || prohibitInput), parseStringToLocalDate = { str, pattern -> getLocalDate(str, pattern) }, onDateInputEntry = { val (display, date) = it @@ -173,15 +179,15 @@ internal object DatePickerViewHolderFactory : QuestionnaireItemComposeViewHolder private fun getSelectableDates( questionnaireViewItem: QuestionnaireViewItem, - ): SelectableDates { + ): 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)) } - - return selectableDates(min, max) } /** Set the answer in the [QuestionnaireResponse]. */ From 5e548755ca0017c290e4b23708996c450a7f9cfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=E2=89=A1ZRS?= <12814349+LZRS@users.noreply.github.com> Date: Thu, 25 Sep 2025 02:30:29 +0300 Subject: [PATCH 03/10] Migrate Timepicker --- .../views/TimePickerViewHolderFactoryTest.kt | 98 ++++++---- .../views/compose/TimePickerDialog.kt | 126 ++++++++++++ .../views/compose/TimePickerItem.kt | 140 +++++++++++++ .../factories/DatePickerViewHolderFactory.kt | 4 +- .../factories/TimePickerViewHolderFactory.kt | 184 ++++++++---------- .../src/main/res/drawable/ic_access_time.xml | 16 ++ .../src/main/res/drawable/ic_keyboard.xml | 12 ++ .../src/main/res/layout/time_picker_view.xml | 57 ------ 8 files changed, 431 insertions(+), 206 deletions(-) rename datacapture/src/{test/java/com/google/android/fhir/datacapture => androidTest/java/com/google/android/fhir/datacapture/test}/views/TimePickerViewHolderFactoryTest.kt (55%) create mode 100644 datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/TimePickerDialog.kt create mode 100644 datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/TimePickerItem.kt create mode 100644 datacapture/src/main/res/drawable/ic_access_time.xml create mode 100644 datacapture/src/main/res/drawable/ic_keyboard.xml delete mode 100644 datacapture/src/main/res/layout/time_picker_view.xml 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/views/compose/TimePickerDialog.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/TimePickerDialog.kt new file mode 100644 index 0000000000..701004a413 --- /dev/null +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/TimePickerDialog.kt @@ -0,0 +1,126 @@ +/* + * 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, + onDismiss: () -> Unit, + onConfirm: (Int, Int) -> Unit, +) { + val timePickerState = rememberTimePickerState() + 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..31b6199ec7 --- /dev/null +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/TimePickerItem.kt @@ -0,0 +1,140 @@ +/* + * 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.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 java.time.LocalTime + +@Composable +internal fun TimePickerItem( + modifier: Modifier = Modifier, + selectedTime: String?, + enabled: Boolean, + hint: String, + supportingHelperText: String?, + isError: Boolean, + onTimeChanged: (LocalTime) -> Unit, +) { + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + var selectedTimeText by remember(selectedTime) { mutableStateOf(selectedTime ?: "") } + var showTimePickerModal by remember { mutableStateOf(false) } + var timePickerDialogType by remember { mutableStateOf(TimeInputMode.CLOCK) } + + OutlinedTextField( + value = selectedTimeText, + onValueChange = {}, + singleLine = true, + label = { Text(hint) }, + modifier = + modifier + .testTag(TIME_PICKER_INPUT_FIELD) + .onFocusChanged { + if (!it.isFocused) { + keyboardController?.hide() + } + } + .semantics { + if (isError && !supportingHelperText.isNullOrBlank()) 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, onDismiss = { showTimePickerModal = false }) { + hour, + min, + -> + val localTime = LocalTime.of(hour, min) + onTimeChanged(localTime) + } + } +} + +@Composable +@Preview +fun PreviewTimePickerItem() { + TimePickerItem(Modifier, null, 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 d32c8cc34f..d4abdd9411 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 @@ -88,7 +88,9 @@ internal object DatePickerViewHolderFactory : QuestionnaireItemComposeViewHolder ) } val questionnaireItemAnswerLocalDate = - questionnaireViewItem.answers.singleOrNull()?.valueDateType?.localDate + remember(questionnaireViewItem.answers) { + questionnaireViewItem.answers.singleOrNull()?.valueDateType?.localDate + } val questionnaireItemAnswerDateInMillis = remember(questionnaireItemAnswerLocalDate) { questionnaireItemAnswerLocalDate?.atStartOfDay(ZONE_ID_UTC)?.toInstant()?.toEpochMilli() 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..62d388002a 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,102 @@ 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.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.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) - ?: "", - ) - } - - 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 - } - } - - 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 + @Composable + override fun Content(questionnaireViewItem: QuestionnaireViewItem) { + val context = LocalContext.current + val validationMessage = + remember(questionnaireViewItem.validationResult) { + getValidationErrorMessage( + context, + questionnaireViewItem, + questionnaireViewItem.validationResult, + ) } - 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() - } - } + val requiredOptionalText = + remember(questionnaireViewItem) { + getRequiredOrOptionalText(questionnaireViewItem, context) } - .show(context.tryUnwrapContext()!!.supportFragmentManager, TAG) - } + val readOnly = + remember(questionnaireViewItem.questionnaireItem) { + questionnaireViewItem.questionnaireItem.readOnly + } + val questionnaireViewItemLocalTimeAnswer = + remember(questionnaireViewItem.answers) { + questionnaireViewItem.answers.singleOrNull()?.valueTimeType?.localTime + } + var questionnaireViewItemLocalTimeAnswerDisplay by + remember(questionnaireViewItemLocalTimeAnswer) { + mutableStateOf(questionnaireViewItemLocalTimeAnswer?.toLocalizedString(context)) + } + val coroutineScope = rememberCoroutineScope { Dispatchers.Main } - /** 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))), - ) + 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(), + selectedTime = questionnaireViewItemLocalTimeAnswerDisplay, + enabled = !readOnly, + hint = stringResource(R.string.time), + supportingHelperText = + if (!validationMessage.isNullOrBlank()) validationMessage else requiredOptionalText, + isError = !validationMessage.isNullOrBlank(), + ) { + questionnaireViewItemLocalTimeAnswerDisplay = it.toLocalizedString(context) + coroutineScope.launch { setQuestionnaireItemViewItemAnswer(questionnaireViewItem, it) } + } } - - private fun clearPreviousState() { - timeInputEditText.isEnabled = true - timeInputLayout.isEnabled = true } + + /** Set the answer in the [QuestionnaireResponse]. */ + 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/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 @@ - - - - - - - - - - - - - - From 25bb6056512bd0c8173858119917386d0593fd64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=E2=89=A1ZRS?= <12814349+LZRS@users.noreply.github.com> Date: Fri, 10 Oct 2025 17:08:08 +0300 Subject: [PATCH 04/10] Migrate DateTimePicker widget with both date and time components --- .../test/QuestionnaireUiEspressoTest.kt | 104 ++-- ...TimePickerViewHolderFactoryEspressoTest.kt | 122 +++-- .../DateTimePickerViewHolderFactoryTest.kt | 363 ++++++++----- .../datacapture/extensions/MoreLocalDates.kt | 11 + .../views/compose/DatePickerItem.kt | 3 +- .../views/compose/TimePickerDialog.kt | 8 +- .../views/compose/TimePickerItem.kt | 36 +- .../factories/DatePickerViewHolderFactory.kt | 18 +- .../DateTimePickerViewHolderFactory.kt | 496 +++++++----------- .../factories/TimePickerViewHolderFactory.kt | 14 +- .../main/res/layout/date_time_picker_view.xml | 100 ---- 11 files changed, 604 insertions(+), 671 deletions(-) rename datacapture/src/{test/java/com/google/android/fhir/datacapture/views/factories => androidTest/java/com/google/android/fhir/datacapture/test/views}/DateTimePickerViewHolderFactoryTest.kt (62%) delete mode 100644 datacapture/src/main/res/layout/date_time_picker_view.xml 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 2862735253..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,29 +19,31 @@ 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.contrib.RecyclerViewActions import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText @@ -55,7 +57,6 @@ import com.google.android.fhir.datacapture.QuestionnaireFragment 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.clickIcon 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 @@ -63,8 +64,8 @@ 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.compose.TIME_PICKER_INPUT_FIELD import com.google.android.material.progressindicator.LinearProgressIndicator -import com.google.android.material.textfield.TextInputLayout import com.google.common.truth.Truth.assertThat import java.math.BigDecimal import java.time.LocalDate @@ -231,57 +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(R.id.date_input_edit_text)) - .perform(ViewActions.click()) - .perform(ViewActions.typeTextIntoFocusedView("0105")) + composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextReplacement("0105") - onView(withId(R.id.date_input_layout)).check { view, _ -> - val actualError = (view as TextInputLayout).error - assertThat(actualError).isEqualTo("Date format needs to be mm/dd/yyyy (e.g. 01/31/2023)") - } - onView(withId(R.id.time_input_layout)).check { view, _ -> assertThat(view.isEnabled).isFalse() } + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.Error, + "Date format needs to be mm/dd/yyyy (e.g. 01/31/2023)", + ), + ) + composeTestRule.onNodeWithTag(TIME_PICKER_INPUT_FIELD).assertIsNotEnabled() } @Test fun dateTimePicker_shouldEnableTimePickerWithCorrectDate_butNotSaveInQuestionnaireResponse() { buildFragmentFromQuestionnaire("/component_date_time_picker.json") - onView(withId(R.id.date_input_edit_text)) - .perform(ViewActions.click()) - .perform(ViewActions.typeTextIntoFocusedView("01052005")) - - onView(withId(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(R.id.time_input_layout)).check { view, _ -> assertThat(view.isEnabled).isTrue() } + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .assert( + SemanticsMatcher.keyNotDefined( + SemanticsProperties.Error, + ), + ) + composeTestRule.onNodeWithTag(TIME_PICKER_INPUT_FIELD).assertIsEnabled() - runBlocking { - assertThat(getQuestionnaireResponse().item.size).isEqualTo(1) - assertThat(getQuestionnaireResponse().item.first().answer.size).isEqualTo(0) - } + val questionnaireResponse = runBlocking { getQuestionnaireResponse() } + assertThat(questionnaireResponse.item.size).isEqualTo(1) + assertThat(questionnaireResponse.item.first().answer.size).isEqualTo(1) + val answer = questionnaireResponse.item.first().answer.first().valueDateTimeType + assertThat(answer.localDateTime).isEqualTo(LocalDateTime.of(2005, 1, 5, 0, 0)) } @Test fun dateTimePicker_shouldSetAnswerWhenDateAndTimeAreFilled() { buildFragmentFromQuestionnaire("/component_date_time_picker.json") - onView(withId(R.id.date_input_edit_text)) - .perform(ViewActions.click()) - .perform(ViewActions.typeTextIntoFocusedView("01052005")) + composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextReplacement("01052005") + + composeTestRule + .onNodeWithTag(TIME_PICKER_INPUT_FIELD) + .onChildren() + .filterToOne( + SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button), + ) + .performClick() + + composeTestRule.onNodeWithText("AM").performClick() + composeTestRule.onNodeWithContentDescription("Select hour", substring = true).performClick() + composeTestRule.onNodeWithContentDescription("6 o'clock", substring = true).performClick() - onView(withId(R.id.time_input_layout)).perform(clickIcon(true)) - clickOnText("AM") - clickOnText("6") - clickOnText("10") - clickOnText("OK") + composeTestRule.onNodeWithContentDescription("Select minutes", substring = true).performClick() + composeTestRule.onNodeWithContentDescription("10 minutes", substring = true).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("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 @@ -649,8 +663,7 @@ class QuestionnaireUiEspressoTest { @Test fun test_repeated_group_is_added() { buildFragmentFromQuestionnaire("/component_repeated_group.json") - onView(withId(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) @@ -660,8 +673,7 @@ class QuestionnaireUiEspressoTest { onView(withId(R.id.repeated_group_instance_header_title)) .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) - onView(withText(R.string.delete)) - .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) + onView(withText(R.string.delete)).check(ViewAssertions.matches(ViewMatchers.isDisplayed())) } @Test @@ -695,11 +707,9 @@ class QuestionnaireUiEspressoTest { onView(withId(R.id.repeated_group_instance_header_title)) .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) - onView(withText(R.string.delete)) - .perform(ViewActions.click()) + onView(withText(R.string.delete)).perform(ViewActions.click()) - onView(withText(R.id.repeated_group_instance_header_title)) - .check(doesNotExist()) + onView(withText(R.id.repeated_group_instance_header_title)).check(doesNotExist()) } private fun buildFragmentFromQuestionnaire( 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/main/java/com/google/android/fhir/datacapture/extensions/MoreLocalDates.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreLocalDates.kt index e1c40d5529..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 @@ -28,6 +28,7 @@ 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 @@ -110,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. 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 index 8b246c218e..4d643e3dad 100644 --- 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 @@ -97,7 +97,6 @@ internal fun DatePickerItem( } else { null } - dateInputState = DateInput(it, localDate) } }, @@ -111,7 +110,7 @@ internal fun DatePickerItem( keyboardController?.hide() } } - .semantics { if (isError && !helperText.isNullOrBlank()) error(helperText) }, + .semantics { if (isError) error(helperText ?: "") }, supportingText = { helperText?.let { Text(it) } }, isError = isError, trailingIcon = { 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 index 701004a413..f245d5628f 100644 --- 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 @@ -45,10 +45,16 @@ import com.google.android.fhir.datacapture.R @Composable fun TimePickerDialog( type: TimeInputMode, + initialSelectedHour: Int = 0, + initialSelectedMinute: Int = 0, onDismiss: () -> Unit, onConfirm: (Int, Int) -> Unit, ) { - val timePickerState = rememberTimePickerState() + val timePickerState = + rememberTimePickerState( + initialHour = initialSelectedHour, + initialMinute = initialSelectedMinute, + ) var inputType by remember(type) { mutableStateOf(type) } AlertDialog( 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 index 31b6199ec7..6e2c0c9e71 100644 --- 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 @@ -33,6 +33,7 @@ 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 @@ -44,26 +45,30 @@ 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, - selectedTime: String?, + 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 selectedTimeText by remember(selectedTime) { mutableStateOf(selectedTime ?: "") } + var selectedTimeTextDisplay by + remember(timeSelectedDisplay) { mutableStateOf(timeSelectedDisplay ?: "") } var showTimePickerModal by remember { mutableStateOf(false) } var timePickerDialogType by remember { mutableStateOf(TimeInputMode.CLOCK) } OutlinedTextField( - value = selectedTimeText, + value = selectedTimeTextDisplay, onValueChange = {}, singleLine = true, label = { Text(hint) }, @@ -75,9 +80,7 @@ internal fun TimePickerItem( keyboardController?.hide() } } - .semantics { - if (isError && !supportingHelperText.isNullOrBlank()) error(supportingHelperText) - }, + .semantics { if (isError) error(supportingHelperText ?: "") }, supportingText = { supportingHelperText?.let { Text(it) } }, isError = isError, trailingIcon = { @@ -121,11 +124,15 @@ internal fun TimePickerItem( ) if (showTimePickerModal) { - TimePickerDialog(type = timePickerDialogType, onDismiss = { showTimePickerModal = false }) { - hour, - min, + 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) } } @@ -134,7 +141,16 @@ internal fun TimePickerItem( @Composable @Preview fun PreviewTimePickerItem() { - TimePickerItem(Modifier, null, true, stringResource(R.string.time), null, false) {} + 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 d4abdd9411..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 @@ -38,7 +38,7 @@ 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.localDate -import com.google.android.fhir.datacapture.extensions.parseDate +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.views.QuestionnaireViewItem @@ -48,10 +48,8 @@ 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 java.text.ParseException import java.time.LocalDate import java.time.ZoneId -import java.time.format.DateTimeParseException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.hl7.fhir.r4.model.DateType @@ -158,7 +156,7 @@ internal object DatePickerViewHolderFactory : QuestionnaireItemComposeViewHolder ?: getRequiredOrOptionalText(questionnaireViewItem, context), isError = !validationMessage.isNullOrBlank(), enabled = !(questionnaireViewItem.questionnaireItem.readOnly || prohibitInput), - parseStringToLocalDate = { str, pattern -> getLocalDate(str, pattern) }, + parseStringToLocalDate = { str, pattern -> parseLocalDateOrNull(str, pattern) }, onDateInputEntry = { val (display, date) = it if (date != null) { @@ -212,23 +210,13 @@ internal object DatePickerViewHolderFactory : QuestionnaireItemComposeViewHolder dateToDisplay: String, pattern: String, ) { - val localDate = getLocalDate(dateToDisplay, pattern) + val localDate = parseLocalDateOrNull(dateToDisplay, pattern) if (localDate != null) { setQuestionnaireItemViewItemAnswer(questionnaireViewItem, localDate) } else { questionnaireViewItem.setDraftAnswer(dateToDisplay) } } - - private fun getLocalDate(dateToDisplay: String, pattern: String): LocalDate? { - return try { - parseDate(dateToDisplay, pattern) - } catch (_: ParseException) { - null - } catch (_: DateTimeParseException) { - null - } - } } } 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 a4e8e535e7..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,330 +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.localDate +import com.google.android.fhir.datacapture.extensions.itemMedia import com.google.android.fhir.datacapture.extensions.localDateTime -import com.google.android.fhir.datacapture.extensions.localTime -import com.google.android.fhir.datacapture.extensions.parseDate +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" - -/** - * 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()) + ) } - } } 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 62d388002a..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 @@ -21,7 +21,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -73,10 +72,15 @@ object TimePickerViewHolderFactory : QuestionnaireItemComposeViewHolderFactory { remember(questionnaireViewItem.answers) { questionnaireViewItem.answers.singleOrNull()?.valueTimeType?.localTime } - var questionnaireViewItemLocalTimeAnswerDisplay by + val initialTimeForSelection = remember(questionnaireViewItemLocalTimeAnswer) { - mutableStateOf(questionnaireViewItemLocalTimeAnswer?.toLocalizedString(context)) + questionnaireViewItemLocalTimeAnswer ?: LocalTime.now() } + val questionnaireViewItemLocalTimeAnswerDisplay = + remember(questionnaireViewItemLocalTimeAnswer) { + questionnaireViewItemLocalTimeAnswer?.toLocalizedString(context) + } + val coroutineScope = rememberCoroutineScope { Dispatchers.Main } Column( @@ -90,14 +94,14 @@ object TimePickerViewHolderFactory : QuestionnaireItemComposeViewHolderFactory { questionnaireViewItem.questionnaireItem.itemMedia?.let { MediaItem(it) } TimePickerItem( modifier = Modifier.fillMaxWidth(), - selectedTime = questionnaireViewItemLocalTimeAnswerDisplay, + initialStartTime = initialTimeForSelection, + timeSelectedDisplay = questionnaireViewItemLocalTimeAnswerDisplay, enabled = !readOnly, hint = stringResource(R.string.time), supportingHelperText = if (!validationMessage.isNullOrBlank()) validationMessage else requiredOptionalText, isError = !validationMessage.isNullOrBlank(), ) { - questionnaireViewItemLocalTimeAnswerDisplay = it.toLocalizedString(context) coroutineScope.launch { setQuestionnaireItemViewItemAnswer(questionnaireViewItem, it) } } } 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 @@ - - - - - - - - - - - - - - - - - - - - - - - - - From 344c4236ff392b47801fce41cc2eaae3d917a68e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=E2=89=A1ZRS?= <12814349+LZRS@users.noreply.github.com> Date: Fri, 21 Nov 2025 03:11:53 +0300 Subject: [PATCH 05/10] Use ExposedDropdownMenuBox in TimePickerItem to manage click To note; the ExposedDropdownMenuBox is specifically designed for displaying a list of selectable items within a menu, and not for triggering dialogs --- .../views/compose/TimePickerItem.kt | 132 +++++++++--------- 1 file changed, 66 insertions(+), 66 deletions(-) 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 index 6e2c0c9e71..081031359c 100644 --- 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 @@ -16,16 +16,16 @@ 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.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox 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 @@ -48,6 +48,7 @@ import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.extensions.toLocalizedString import java.time.LocalTime +@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun TimePickerItem( modifier: Modifier = Modifier, @@ -64,76 +65,75 @@ internal fun TimePickerItem( val keyboardController = LocalSoftwareKeyboardController.current var selectedTimeTextDisplay by remember(timeSelectedDisplay) { mutableStateOf(timeSelectedDisplay ?: "") } - var showTimePickerModal by remember { mutableStateOf(false) } var timePickerDialogType by remember { mutableStateOf(TimeInputMode.CLOCK) } + var expanded by remember { mutableStateOf(false) } - 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), - ) + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { + if (it) { + timePickerDialogType = TimeInputMode.KEYBOARD } + expanded = it }, - 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 - } + ) { + OutlinedTextField( + value = selectedTimeTextDisplay, + onValueChange = {}, + singleLine = true, + label = { Text(hint) }, + modifier = + modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable, enabled) + .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 + expanded = 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) }, + ), + ) - 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) + if (expanded) { + TimePickerDialog( + type = timePickerDialogType, + initialSelectedHour = initialStartTime.hour, + initialSelectedMinute = initialStartTime.minute, + onDismiss = { expanded = false }, + ) { hour, min, + -> + val localTime = LocalTime.of(hour, min) + selectedTimeTextDisplay = localTime.toLocalizedString(context) + onTimeChanged(localTime) + } } } } From 268a71d4dc90ff7053433d99ac6dd17dfa49cd7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=E2=89=A1ZRS?= <12814349+LZRS@users.noreply.github.com> Date: Fri, 21 Nov 2025 14:31:27 +0300 Subject: [PATCH 06/10] Update datepicker textfield to be state-based To simplify date formatting visualization and synchronization --- .../views/compose/DatePickerItem.kt | 104 +++++++++++----- .../views/compose/DateVisualTransformation.kt | 78 ------------ .../compose/DateVisualTransformationTest.kt | 117 ------------------ 3 files changed, 72 insertions(+), 227 deletions(-) delete mode 100644 datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/DateVisualTransformation.kt delete mode 100644 datacapture/src/test/java/com/google/android/fhir/datacapture/views/compose/DateVisualTransformationTest.kt 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 index 4d643e3dad..d7387c8d26 100644 --- 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 @@ -16,8 +16,15 @@ package com.google.android.fhir.datacapture.views.compose -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.OutputTransformation +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.insert +import androidx.compose.foundation.text.input.maxLength +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.foundation.text.input.then import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.ExperimentalMaterial3Api @@ -35,10 +42,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow 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 @@ -51,6 +57,7 @@ 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 +import kotlinx.coroutines.flow.collectLatest @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -67,47 +74,67 @@ internal fun DatePickerItem( 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 } } + val textFieldState = rememberTextFieldState(dateInputState.display) + var isFocused by remember { mutableStateOf(false) } + + val firstDelimiterIndex = + remember(dateInputFormat) { + dateInputFormat.patternWithDelimiters.indexOf(dateInputFormat.delimiter).takeIf { it >= 0 } + } + val secondDelimiterIndex = + remember(dateInputFormat) { + dateInputFormat.patternWithDelimiters.lastIndexOf(dateInputFormat.delimiter).takeIf { + it >= 0 + } + } + val dateFormatLength = + remember(dateInputFormat) { dateInputFormat.patternWithoutDelimiters.length } var showDatePickerModal by remember { mutableStateOf(false) } - LaunchedEffect(dateInputState) { - if (dateInputState != dateInput) { - onDateInputEntry(dateInputState) + // Sync external dateInput changes to textFieldState + LaunchedEffect(dateInput) { + if (!isFocused && dateInput.display != textFieldState.text.toString()) { + textFieldState.setTextAndPlaceCursorAtEnd(dateInput.display) } } - OutlinedTextField( - value = dateInputDisplay, - onValueChange = { - if ( - it.length <= dateInputFormat.patternWithoutDelimiters.length && - it.all { char -> char.isDigit() } - ) { + // Monitor textFieldState changes and update dateInputState + LaunchedEffect(textFieldState) { + snapshotFlow { textFieldState.text.toString() } + .collectLatest { val trimmedText = it.trim() val localDate = - if ( - trimmedText.isNotBlank() && - trimmedText.length == dateInputFormat.patternWithoutDelimiters.length - ) { + if (trimmedText.isNotBlank() && trimmedText.length == dateFormatLength) { parseStringToLocalDate(trimmedText, dateInputFormat.patternWithoutDelimiters) } else { null } - dateInputState = DateInput(it, localDate) + val newDateInput = DateInput(trimmedText, localDate) + if (dateInputState != newDateInput) { + dateInputState = newDateInput + onDateInputEntry(newDateInput) + } } - }, - singleLine = true, + } + + OutlinedTextField( + state = textFieldState, + lineLimits = TextFieldLineLimits.SingleLine, label = { Text(labelText) }, modifier = modifier .testTag(DATE_TEXT_INPUT_FIELD) .onFocusChanged { + isFocused = it.isFocused if (!it.isFocused) { keyboardController?.hide() + // Sync external dateInput changes to textFieldState + if (dateInput.display != textFieldState.text.toString()) { + textFieldState.setTextAndPlaceCursorAtEnd(dateInput.display) + } } } .semantics { if (isError) error(helperText ?: "") }, @@ -122,17 +149,29 @@ internal fun DatePickerItem( } }, enabled = enabled, + inputTransformation = + InputTransformation.maxLength(dateFormatLength).then { + if (asCharSequence().any { !Character.isDigit(it) }) revertAllChanges() + }, keyboardOptions = KeyboardOptions( autoCorrectEnabled = false, keyboardType = KeyboardType.Number, imeAction = ImeAction.Done, ), - keyboardActions = - KeyboardActions( - onNext = { focusManager.moveFocus(FocusDirection.Down) }, - ), - visualTransformation = DateVisualTransformation(dateInputFormat), + outputTransformation = + OutputTransformation { + firstDelimiterIndex?.let { + if (length >= firstDelimiterIndex) { + insert(firstDelimiterIndex, dateInputFormat.delimiter.toString()) + } + } + secondDelimiterIndex?.let { + if (length >= secondDelimiterIndex) { + insert(secondDelimiterIndex, dateInputFormat.delimiter.toString()) + } + } + }, ) if (selectableDates != null && showDatePickerModal) { @@ -141,11 +180,8 @@ internal fun DatePickerItem( selectableDates, onDateSelected = { dateMillis -> dateMillis?.toLocalDate()?.let { - dateInputState = - DateInput( - display = it.format(dateInputFormat.patternWithoutDelimiters), - value = it, - ) + val formattedDate = it.format(dateInputFormat.patternWithoutDelimiters) + textFieldState.setTextAndPlaceCursorAtEnd(formattedDate) } }, ) { @@ -197,4 +233,8 @@ typealias DateFormatPattern = String data class DateInput(val display: String, val value: LocalDate?) +data class DateInputFormat(val patternWithDelimiters: String, val delimiter: Char) { + val patternWithoutDelimiters: String = patternWithDelimiters.replace(delimiter.toString(), "") +} + 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 deleted file mode 100644 index c498121c9c..0000000000 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/DateVisualTransformation.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * 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/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 deleted file mode 100644 index 1f48b89d39..0000000000 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/compose/DateVisualTransformationTest.kt +++ /dev/null @@ -1,117 +0,0 @@ -/* - * 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)) - } -} From a10279104aafe26d53040b4539ddfe13809bae53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=E2=89=A1ZRS?= <12814349+LZRS@users.noreply.github.com> Date: Sat, 22 Nov 2025 11:49:20 +0300 Subject: [PATCH 07/10] Revert "Update datepicker textfield to be state-based" This reverts commit 268a71d4dc90ff7053433d99ac6dd17dfa49cd7e. --- .../views/compose/DatePickerItem.kt | 104 +++++----------- .../views/compose/DateVisualTransformation.kt | 78 ++++++++++++ .../compose/DateVisualTransformationTest.kt | 117 ++++++++++++++++++ 3 files changed, 227 insertions(+), 72 deletions(-) create mode 100644 datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/DateVisualTransformation.kt create mode 100644 datacapture/src/test/java/com/google/android/fhir/datacapture/views/compose/DateVisualTransformationTest.kt 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 index d7387c8d26..4d643e3dad 100644 --- 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 @@ -16,15 +16,8 @@ package com.google.android.fhir.datacapture.views.compose +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.input.InputTransformation -import androidx.compose.foundation.text.input.OutputTransformation -import androidx.compose.foundation.text.input.TextFieldLineLimits -import androidx.compose.foundation.text.input.insert -import androidx.compose.foundation.text.input.maxLength -import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd -import androidx.compose.foundation.text.input.then import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.ExperimentalMaterial3Api @@ -42,9 +35,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow 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 @@ -57,7 +51,6 @@ 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 -import kotlinx.coroutines.flow.collectLatest @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -74,67 +67,47 @@ internal fun DatePickerItem( parseStringToLocalDate: (String, DateFormatPattern) -> LocalDate?, onDateInputEntry: (DateInput) -> Unit, ) { + val focusManager = LocalFocusManager.current val keyboardController = LocalSoftwareKeyboardController.current var dateInputState by remember(dateInput) { mutableStateOf(dateInput) } - val textFieldState = rememberTextFieldState(dateInputState.display) - var isFocused by remember { mutableStateOf(false) } - - val firstDelimiterIndex = - remember(dateInputFormat) { - dateInputFormat.patternWithDelimiters.indexOf(dateInputFormat.delimiter).takeIf { it >= 0 } - } - val secondDelimiterIndex = - remember(dateInputFormat) { - dateInputFormat.patternWithDelimiters.lastIndexOf(dateInputFormat.delimiter).takeIf { - it >= 0 - } - } - val dateFormatLength = - remember(dateInputFormat) { dateInputFormat.patternWithoutDelimiters.length } + val dateInputDisplay by remember(dateInputState) { derivedStateOf { dateInputState.display } } var showDatePickerModal by remember { mutableStateOf(false) } - // Sync external dateInput changes to textFieldState - LaunchedEffect(dateInput) { - if (!isFocused && dateInput.display != textFieldState.text.toString()) { - textFieldState.setTextAndPlaceCursorAtEnd(dateInput.display) + LaunchedEffect(dateInputState) { + if (dateInputState != dateInput) { + onDateInputEntry(dateInputState) } } - // Monitor textFieldState changes and update dateInputState - LaunchedEffect(textFieldState) { - snapshotFlow { textFieldState.text.toString() } - .collectLatest { + 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 == dateFormatLength) { + if ( + trimmedText.isNotBlank() && + trimmedText.length == dateInputFormat.patternWithoutDelimiters.length + ) { parseStringToLocalDate(trimmedText, dateInputFormat.patternWithoutDelimiters) } else { null } - val newDateInput = DateInput(trimmedText, localDate) - if (dateInputState != newDateInput) { - dateInputState = newDateInput - onDateInputEntry(newDateInput) - } + dateInputState = DateInput(it, localDate) } - } - - OutlinedTextField( - state = textFieldState, - lineLimits = TextFieldLineLimits.SingleLine, + }, + singleLine = true, label = { Text(labelText) }, modifier = modifier .testTag(DATE_TEXT_INPUT_FIELD) .onFocusChanged { - isFocused = it.isFocused if (!it.isFocused) { keyboardController?.hide() - // Sync external dateInput changes to textFieldState - if (dateInput.display != textFieldState.text.toString()) { - textFieldState.setTextAndPlaceCursorAtEnd(dateInput.display) - } } } .semantics { if (isError) error(helperText ?: "") }, @@ -149,29 +122,17 @@ internal fun DatePickerItem( } }, enabled = enabled, - inputTransformation = - InputTransformation.maxLength(dateFormatLength).then { - if (asCharSequence().any { !Character.isDigit(it) }) revertAllChanges() - }, keyboardOptions = KeyboardOptions( autoCorrectEnabled = false, keyboardType = KeyboardType.Number, imeAction = ImeAction.Done, ), - outputTransformation = - OutputTransformation { - firstDelimiterIndex?.let { - if (length >= firstDelimiterIndex) { - insert(firstDelimiterIndex, dateInputFormat.delimiter.toString()) - } - } - secondDelimiterIndex?.let { - if (length >= secondDelimiterIndex) { - insert(secondDelimiterIndex, dateInputFormat.delimiter.toString()) - } - } - }, + keyboardActions = + KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Down) }, + ), + visualTransformation = DateVisualTransformation(dateInputFormat), ) if (selectableDates != null && showDatePickerModal) { @@ -180,8 +141,11 @@ internal fun DatePickerItem( selectableDates, onDateSelected = { dateMillis -> dateMillis?.toLocalDate()?.let { - val formattedDate = it.format(dateInputFormat.patternWithoutDelimiters) - textFieldState.setTextAndPlaceCursorAtEnd(formattedDate) + dateInputState = + DateInput( + display = it.format(dateInputFormat.patternWithoutDelimiters), + value = it, + ) } }, ) { @@ -233,8 +197,4 @@ typealias DateFormatPattern = String data class DateInput(val display: String, val value: LocalDate?) -data class DateInputFormat(val patternWithDelimiters: String, val delimiter: Char) { - val patternWithoutDelimiters: String = patternWithDelimiters.replace(delimiter.toString(), "") -} - 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/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)) + } +} From 9a268936a94473e21c8864b9f8d4f74556307d01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=E2=89=A1ZRS?= <12814349+LZRS@users.noreply.github.com> Date: Sat, 22 Nov 2025 14:00:58 +0300 Subject: [PATCH 08/10] Use TextFieldValue instead to set and manage cursor position --- .../views/compose/DatePickerItem.kt | 76 ++++++++++++++++-- .../views/compose/DateVisualTransformation.kt | 78 ------------------- .../views/compose/TimePickerItem.kt | 4 +- 3 files changed, 73 insertions(+), 85 deletions(-) delete mode 100644 datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/DateVisualTransformation.kt 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 index 4d643e3dad..1c33887992 100644 --- 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 @@ -45,8 +45,14 @@ 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.TextRange +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.extensions.format import com.google.android.fhir.datacapture.extensions.toLocalDate @@ -70,7 +76,15 @@ internal fun DatePickerItem( val focusManager = LocalFocusManager.current val keyboardController = LocalSoftwareKeyboardController.current var dateInputState by remember(dateInput) { mutableStateOf(dateInput) } - val dateInputDisplay by remember(dateInputState) { derivedStateOf { dateInputState.display } } + val dateInputDisplay by + remember(dateInputState) { + derivedStateOf { + TextFieldValue( + text = dateInputState.display, + selection = TextRange(dateInputFormat.patternWithDelimiters.length), + ) + } + } var showDatePickerModal by remember { mutableStateOf(false) } @@ -83,11 +97,12 @@ internal fun DatePickerItem( OutlinedTextField( value = dateInputDisplay, onValueChange = { + val text = it.text if ( - it.length <= dateInputFormat.patternWithoutDelimiters.length && - it.all { char -> char.isDigit() } + text.length <= dateInputFormat.patternWithoutDelimiters.length && + text.all { char -> char.isDigit() } ) { - val trimmedText = it.trim() + val trimmedText = text.trim() val localDate = if ( trimmedText.isNotBlank() && @@ -97,7 +112,7 @@ internal fun DatePickerItem( } else { null } - dateInputState = DateInput(it, localDate) + dateInputState = DateInput(text, localDate) } }, singleLine = true, @@ -132,7 +147,25 @@ internal fun DatePickerItem( KeyboardActions( onNext = { focusManager.moveFocus(FocusDirection.Down) }, ), - visualTransformation = DateVisualTransformation(dateInputFormat), + visualTransformation = + if (!dateInputFormat.delimiterExistsInPattern) { + VisualTransformation.None + } else { + VisualTransformation { originalText -> + val text = buildAnnotatedString { + originalText.forEachIndexed { index, ch -> + append(ch) + if ( + index + 1 == dateInputFormat.delimiterFirstIndex || + index + 2 == dateInputFormat.delimiterLastIndex + ) { + append(dateInputFormat.delimiter) + } + } + } + TransformedText(text, dateInputFormat.offsetMapping) + } + }, ) if (selectableDates != null && showDatePickerModal) { @@ -197,4 +230,35 @@ typealias DateFormatPattern = String data class DateInput(val display: String, val value: LocalDate?) +data class DateInputFormat(val patternWithDelimiters: String, val delimiter: Char) { + val patternWithoutDelimiters: String = patternWithDelimiters.replace(delimiter.toString(), "") + + val delimiterFirstIndex: Int = patternWithDelimiters.indexOf(delimiter) + val delimiterLastIndex: Int = patternWithDelimiters.lastIndexOf(delimiter) + val delimiterExistsInPattern = delimiterFirstIndex != -1 && delimiterLastIndex != -1 + + val offsetMapping = + object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + return when { + delimiterExistsInPattern && + offset >= delimiterLastIndex && + delimiterLastIndex > delimiterFirstIndex -> offset + 2 + delimiterExistsInPattern && offset >= delimiterFirstIndex -> offset + 1 + else -> offset + } + } + + override fun transformedToOriginal(offset: Int): Int { + return when { + delimiterExistsInPattern && + offset >= delimiterLastIndex && + offset > delimiterFirstIndex -> offset - 2 + delimiterExistsInPattern && offset >= delimiterFirstIndex -> offset - 1 + else -> offset + } + } + } +} + 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 deleted file mode 100644 index c498121c9c..0000000000 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/DateVisualTransformation.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * 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/TimePickerItem.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/TimePickerItem.kt index 081031359c..2de1a059e0 100644 --- 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 @@ -16,6 +16,7 @@ package com.google.android.fhir.datacapture.views.compose +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.ExperimentalMaterial3Api @@ -69,6 +70,7 @@ internal fun TimePickerItem( var expanded by remember { mutableStateOf(false) } ExposedDropdownMenuBox( + modifier = modifier, expanded = expanded, onExpandedChange = { if (it) { @@ -83,7 +85,7 @@ internal fun TimePickerItem( singleLine = true, label = { Text(hint) }, modifier = - modifier + Modifier.fillMaxWidth() .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable, enabled) .testTag(TIME_PICKER_INPUT_FIELD) .onFocusChanged { From 4fcf123795153d1c9fd03bd44806294a0df862f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=E2=89=A1ZRS?= <12814349+LZRS@users.noreply.github.com> Date: Sun, 23 Nov 2025 02:38:07 +0300 Subject: [PATCH 09/10] Replace date visualTransformation with manual insert of delimiter --- .../test/QuestionnaireUiEspressoTest.kt | 169 ++++++++++-------- .../views/DatePickerViewHolderFactoryTest.kt | 18 +- .../DateTimePickerViewHolderFactoryTest.kt | 22 +-- .../views/compose/DatePickerItem.kt | 162 +++++++++-------- .../factories/DatePickerViewHolderFactory.kt | 12 +- .../DateTimePickerViewHolderFactory.kt | 3 +- .../compose/DateVisualTransformationTest.kt | 117 ------------ 7 files changed, 200 insertions(+), 303 deletions(-) delete mode 100644 datacapture/src/test/java/com/google/android/fhir/datacapture/views/compose/DateVisualTransformationTest.kt 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 07f178aab1..ec767173d7 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 @@ -72,6 +72,7 @@ import java.time.LocalDate import java.time.LocalDateTime import java.util.Calendar import java.util.Date +import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import org.hamcrest.CoreMatchers @@ -231,99 +232,113 @@ class QuestionnaireUiEspressoTest { fun dateTimePicker_shouldShowErrorForWrongDate() { buildFragmentFromQuestionnaire("/component_date_time_picker.json") - // Add month and day. No need to add slashes as they are added automatically - composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextReplacement("0105") + runBlocking { + // Add month and day. No need to add slashes as they are added automatically + composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextReplacement("0105") + delay(HANDLE_INPUT_DEBOUNCE_TIME + 10L) - 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() + 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") - composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextReplacement("01052005") + runBlocking { + composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextReplacement("01052005") + delay(HANDLE_INPUT_DEBOUNCE_TIME + 10L) - composeTestRule - .onNodeWithTag(DATE_TEXT_INPUT_FIELD) - .assert( - SemanticsMatcher.keyNotDefined( - SemanticsProperties.Error, - ), - ) - composeTestRule.onNodeWithTag(TIME_PICKER_INPUT_FIELD).assertIsEnabled() + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .assert( + SemanticsMatcher.keyNotDefined( + SemanticsProperties.Error, + ), + ) + composeTestRule.onNodeWithTag(TIME_PICKER_INPUT_FIELD).assertIsEnabled() - 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)) + val questionnaireResponse = 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") - composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextReplacement("01052005") - - composeTestRule - .onNodeWithTag(TIME_PICKER_INPUT_FIELD) - .onChildren() - .filterToOne( - SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button), - ) - .performClick() - - 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() + runBlocking { + composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextReplacement("01052005") + delay(HANDLE_INPUT_DEBOUNCE_TIME + 10L) + composeTestRule + .onNodeWithTag(TIME_PICKER_INPUT_FIELD) + .onChildren() + .filterToOne( + SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button), + ) + .performClick() - composeTestRule.onNodeWithText("OK").performClick() - // Synchronize - composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("AM").performClick() + composeTestRule.onNodeWithContentDescription("Select hour", substring = true).performClick() + composeTestRule.onNodeWithContentDescription("6 o'clock", substring = true).performClick() - 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)) + composeTestRule + .onNodeWithContentDescription("Select minutes", substring = true) + .performClick() + composeTestRule.onNodeWithContentDescription("10 minutes", substring = true).performClick() + + composeTestRule.onNodeWithText("OK").performClick() + // Synchronize + composeTestRule.waitForIdle() + + val questionnaireResponse = getQuestionnaireResponse() + val answer = questionnaireResponse.item.first().answer.first().valueDateTimeType + // check Locale + assertThat(answer.localDateTime).isEqualTo(LocalDateTime.of(2005, 1, 5, 6, 10)) + } } @Test fun datePicker_shouldShowErrorForWrongDate() { buildFragmentFromQuestionnaire("/component_date_picker.json") - // Add month and day. No need to add slashes as they are added automatically - 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)", - ), - ) + runBlocking { + // Add month and day. No need to add slashes as they are added automatically + composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextInput("0105") + delay(HANDLE_INPUT_DEBOUNCE_TIME + 10L) + 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") - composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextInput("01052005") - composeTestRule - .onNodeWithTag(DATE_TEXT_INPUT_FIELD) - .assert(SemanticsMatcher.keyNotDefined(SemanticsProperties.Error)) - runBlocking { + composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextInput("01052005") + delay(HANDLE_INPUT_DEBOUNCE_TIME + 10L) + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .assert(SemanticsMatcher.keyNotDefined(SemanticsProperties.Error)) + val answer = getQuestionnaireResponse().item.first().answer.first().valueDateType assertThat(answer.localDate).isEqualTo(LocalDate.of(2005, 1, 5)) } @@ -579,19 +594,25 @@ class QuestionnaireUiEspressoTest { @Test fun clearAllAnswers_shouldClearDraftAnswer() { val questionnaireFragment = buildFragmentFromQuestionnaire("/component_date_picker.json") - // Add month and day. No need to add slashes as they are added automatically - composeTestRule - .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) - .performTextInput("0105") - composeTestRule - .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) - .assertTextEquals("01/05/") - questionnaireFragment.clearAllAnswers() + runBlocking { + // Add month and day. No need to add slashes as they are added automatically + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) + .performTextInput("0105") + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals("01/05") + delay(1.seconds) // Add delay to give time for new questionnaire state + composeTestRule.awaitIdle() - composeTestRule - .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) - .assertTextEquals("") + questionnaireFragment.clearAllAnswers() + composeTestRule.awaitIdle() + + composeTestRule + .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) + .assertTextEquals("") + } } @Test diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DatePickerViewHolderFactoryTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DatePickerViewHolderFactoryTest.kt index 9a3cd25245..a169245b53 100644 --- a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DatePickerViewHolderFactoryTest.kt +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DatePickerViewHolderFactoryTest.kt @@ -223,8 +223,7 @@ class DatePickerViewHolderFactoryTest { ) viewHolder.bind(item) - val dateTextInput = "11192020" // is transformed to 11/19/2020 in the date widget - composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextInput(dateTextInput) + composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextInput("11/19/2020") composeTestRule.waitUntil { answers != null } val answer = answers!!.single().value as DateType @@ -246,7 +245,7 @@ class DatePickerViewHolderFactoryTest { answersChangedCallback = { _, _, result, _ -> answers = result }, ) viewHolder.bind(item) - composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextInput("20201119") + composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextInput("2020/11/19") composeTestRule.waitUntil { answers != null } val answer = answers!!.single().value as DateType @@ -303,15 +302,14 @@ class DatePickerViewHolderFactoryTest { 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()) + it("11/19".toAnnotatedString()) } composeTestRule .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) - .assertTextEquals("11/19/") + .assertTextEquals("11/19") } @Test @@ -388,13 +386,13 @@ class DatePickerViewHolderFactoryTest { QuestionnaireResponse.QuestionnaireResponseItemComponent(), validationResult = NotValidated, answersChangedCallback = { _, _, _, _ -> }, - draftAnswer = "0207", + draftAnswer = "02/07", ) viewHolder.bind(questionnaireItem) composeTestRule .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) - .assertTextEquals("02/07/") + .assertTextEquals("02/07") } @Test @@ -406,13 +404,13 @@ class DatePickerViewHolderFactoryTest { QuestionnaireResponse.QuestionnaireResponseItemComponent(), validationResult = NotValidated, answersChangedCallback = { _, _, _, _ -> }, - draftAnswer = "0207", + draftAnswer = "02/07", ) viewHolder.bind(questionnaireItem) composeTestRule .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) - .assertTextEquals("02/07/") + .assertTextEquals("02/07") questionnaireItem = QuestionnaireViewItem( diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DateTimePickerViewHolderFactoryTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DateTimePickerViewHolderFactoryTest.kt index 747ca57e2c..41947f0eb4 100644 --- a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DateTimePickerViewHolderFactoryTest.kt +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DateTimePickerViewHolderFactoryTest.kt @@ -171,9 +171,7 @@ class DateTimePickerViewHolderFactoryTest { ) viewHolder.bind(itemViewItem) - composeTestRule - .onNodeWithTag(DATE_TEXT_INPUT_FIELD) - .performTextReplacement("11192020") // transforms to 11/19/2020 in the date widget + composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextReplacement("11/19/2020") composeTestRule.waitUntil { answer != null } val dateTime = answer!!.value as DateTimeType @@ -199,9 +197,7 @@ class DateTimePickerViewHolderFactoryTest { ) viewHolder.bind(itemViewItem) - composeTestRule - .onNodeWithTag(DATE_TEXT_INPUT_FIELD) - .performTextReplacement("20201119") // transforms to 2020/11/19 in the date widget + composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextReplacement("2020/11/19") composeTestRule.waitUntil { answer != null } val dateTime = answer!!.value as DateTimeType @@ -255,13 +251,11 @@ class DateTimePickerViewHolderFactoryTest { answersChangedCallback = { _, _, _, _ -> }, ) viewHolder.bind(itemViewItem) - composeTestRule - .onNodeWithTag(DATE_TEXT_INPUT_FIELD) - .performTextReplacement("202011") // transforms to 2020/11 for Locale.JAPAN + composeTestRule.onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextReplacement("2020/11") composeTestRule .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) - .assertTextEquals("2020/11/") + .assertTextEquals("2020/11") } @Test @@ -335,13 +329,13 @@ class DateTimePickerViewHolderFactoryTest { QuestionnaireResponse.QuestionnaireResponseItemComponent(), validationResult = NotValidated, answersChangedCallback = { _, _, _, _ -> }, - draftAnswer = "0207", // transforms to 02/07 for default locale + draftAnswer = "02/07", ) viewHolder.bind(questionnaireItem) composeTestRule .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) - .assertTextEquals("02/07/") + .assertTextEquals("02/07") } @Test @@ -352,13 +346,13 @@ class DateTimePickerViewHolderFactoryTest { QuestionnaireResponse.QuestionnaireResponseItemComponent(), validationResult = NotValidated, answersChangedCallback = { _, _, _, _ -> }, - draftAnswer = "0207", // transforms to 02/07 for default locale + draftAnswer = "02/07", ) viewHolder.bind(questionnaireItem) composeTestRule .onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) - .assertTextEquals("02/07/") + .assertTextEquals("02/07") questionnaireItem = QuestionnaireViewItem( 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 index 1c33887992..155a8643d3 100644 --- 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 @@ -34,6 +34,7 @@ import androidx.compose.runtime.derivedStateOf 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.focus.FocusDirection @@ -46,17 +47,17 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.error import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.OffsetMapping import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.input.TransformedText -import androidx.compose.ui.text.input.VisualTransformation 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 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -75,45 +76,78 @@ internal fun DatePickerItem( ) { val focusManager = LocalFocusManager.current val keyboardController = LocalSoftwareKeyboardController.current - var dateInputState by remember(dateInput) { mutableStateOf(dateInput) } - val dateInputDisplay by - remember(dateInputState) { - derivedStateOf { + val coroutineScope = rememberCoroutineScope { Dispatchers.Main } + + var dateInputDisplay by + remember(dateInput) { + mutableStateOf( TextFieldValue( - text = dateInputState.display, - selection = TextRange(dateInputFormat.patternWithDelimiters.length), - ) - } + text = dateInput.display, + selection = TextRange(dateInputFormat.pattern.length), + ), + ) } var showDatePickerModal by remember { mutableStateOf(false) } - - LaunchedEffect(dateInputState) { - if (dateInputState != dateInput) { - onDateInputEntry(dateInputState) + var typingJob by remember { mutableStateOf(null) } + val postDelayedNewDateInput: (DateInput, Long) -> Unit = remember { + { newDateInput, delayInMillis -> + typingJob?.cancel() // Cancel previous debounce + typingJob = + coroutineScope.launch { + delay(delayInMillis) // Debounce delay + if (newDateInput != dateInput) { + onDateInputEntry(newDateInput) + } + } } } OutlinedTextField( value = dateInputDisplay, - onValueChange = { - val text = it.text - if ( - text.length <= dateInputFormat.patternWithoutDelimiters.length && - text.all { char -> char.isDigit() } - ) { - val trimmedText = text.trim() - val localDate = - if ( - trimmedText.isNotBlank() && - trimmedText.length == dateInputFormat.patternWithoutDelimiters.length - ) { - parseStringToLocalDate(trimmedText, dateInputFormat.patternWithoutDelimiters) - } else { - null - } - dateInputState = DateInput(text, localDate) - } + onValueChange = { textFieldValue -> + textFieldValue.text + .takeIf { + it.length <= dateInputFormat.pattern.length && + it.all { char -> char.isDigit() || char == dateInputFormat.delimiter } + } + ?.let { + val isDeletion = it.length < dateInputDisplay.text.length + val formattedText = + if (!dateInputFormat.delimiterExistsInPattern || isDeletion) { + it + } else { + StringBuilder(it) + .apply { + if ( + this.length > dateInputFormat.delimiterFirstIndex && + get(dateInputFormat.delimiterFirstIndex) != dateInputFormat.delimiter + ) { + insert(dateInputFormat.delimiterFirstIndex, dateInputFormat.delimiter) + } + if ( + this.length > dateInputFormat.delimiterLastIndex && + dateInputFormat.delimiterLastIndex > dateInputFormat.delimiterFirstIndex && + get(dateInputFormat.delimiterLastIndex) != dateInputFormat.delimiter + ) { + insert(dateInputFormat.delimiterLastIndex, dateInputFormat.delimiter) + } + } + .toString() + } + val localDate = + if (formattedText.length == dateInputFormat.pattern.length) { + parseStringToLocalDate(formattedText, dateInputFormat.pattern) + } else { + null + } + dateInputDisplay = + dateInputDisplay.copy( + text = formattedText, + selection = TextRange(dateInputFormat.pattern.length), + ) + postDelayedNewDateInput(DateInput(formattedText, localDate), HANDLE_INPUT_DEBOUNCE_TIME) + } }, singleLine = true, label = { Text(labelText) }, @@ -147,25 +181,6 @@ internal fun DatePickerItem( KeyboardActions( onNext = { focusManager.moveFocus(FocusDirection.Down) }, ), - visualTransformation = - if (!dateInputFormat.delimiterExistsInPattern) { - VisualTransformation.None - } else { - VisualTransformation { originalText -> - val text = buildAnnotatedString { - originalText.forEachIndexed { index, ch -> - append(ch) - if ( - index + 1 == dateInputFormat.delimiterFirstIndex || - index + 2 == dateInputFormat.delimiterLastIndex - ) { - append(dateInputFormat.delimiter) - } - } - } - TransformedText(text, dateInputFormat.offsetMapping) - } - }, ) if (selectableDates != null && showDatePickerModal) { @@ -174,11 +189,18 @@ internal fun DatePickerItem( selectableDates, onDateSelected = { dateMillis -> dateMillis?.toLocalDate()?.let { - dateInputState = + val dateDisplay = it.format(dateInputFormat.pattern) + dateInputDisplay = + dateInputDisplay.copy( + text = dateDisplay, + selection = TextRange(dateInputFormat.pattern.length), + ) + val newDateInput = DateInput( - display = it.format(dateInputFormat.patternWithoutDelimiters), + display = dateDisplay, value = it, ) + postDelayedNewDateInput(newDateInput, 0L) } }, ) { @@ -230,35 +252,11 @@ typealias DateFormatPattern = String data class DateInput(val display: String, val value: LocalDate?) -data class DateInputFormat(val patternWithDelimiters: String, val delimiter: Char) { - val patternWithoutDelimiters: String = patternWithDelimiters.replace(delimiter.toString(), "") +data class DateInputFormat(val pattern: String, val delimiter: Char) { - val delimiterFirstIndex: Int = patternWithDelimiters.indexOf(delimiter) - val delimiterLastIndex: Int = patternWithDelimiters.lastIndexOf(delimiter) + val delimiterFirstIndex: Int = pattern.indexOf(delimiter) + val delimiterLastIndex: Int = pattern.lastIndexOf(delimiter) val delimiterExistsInPattern = delimiterFirstIndex != -1 && delimiterLastIndex != -1 - - val offsetMapping = - object : OffsetMapping { - override fun originalToTransformed(offset: Int): Int { - return when { - delimiterExistsInPattern && - offset >= delimiterLastIndex && - delimiterLastIndex > delimiterFirstIndex -> offset + 2 - delimiterExistsInPattern && offset >= delimiterFirstIndex -> offset + 1 - else -> offset - } - } - - override fun transformedToOriginal(offset: Int): Int { - return when { - delimiterExistsInPattern && - offset >= delimiterLastIndex && - offset > delimiterFirstIndex -> offset - 2 - delimiterExistsInPattern && offset >= delimiterFirstIndex -> offset - 1 - else -> offset - } - } - } } const val DATE_TEXT_INPUT_FIELD = "date_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 a33b5ab404..9d8438d3c3 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 @@ -98,12 +98,14 @@ internal object DatePickerViewHolderFactory : QuestionnaireItemComposeViewHolder questionnaireItemAnswerDateInMillis ?: MaterialDatePicker.todayInUtcMilliseconds() } val draftAnswer = - remember(questionnaireViewItem) { questionnaireViewItem.draftAnswer as? String } + remember(questionnaireViewItem.draftAnswer) { + questionnaireViewItem.draftAnswer as? String + } val dateInput = remember(dateInputFormat, questionnaireItemAnswerLocalDate, draftAnswer) { - questionnaireItemAnswerLocalDate - ?.format(dateInputFormat.patternWithoutDelimiters) - ?.let { DateInput(it, questionnaireItemAnswerLocalDate) } + questionnaireItemAnswerLocalDate?.format(dateInputFormat.pattern)?.let { + DateInput(it, questionnaireItemAnswerLocalDate) + } ?: DateInput(display = draftAnswer ?: "", null) } @@ -168,7 +170,7 @@ internal object DatePickerViewHolderFactory : QuestionnaireItemComposeViewHolder parseDateOnTextChanged( questionnaireViewItem, display, - dateInputFormat.patternWithoutDelimiters, + dateInputFormat.pattern, ) } } 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 2845f24e9d..a42370bc4d 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 @@ -129,7 +129,7 @@ internal object DateTimePickerViewHolderFactory : QuestionnaireItemComposeViewHo remember(questionnaireViewItem) { questionnaireViewItem.draftAnswer as? String } val dateInput = remember(dateInputFormat, questionnaireItemViewItemDate, draftAnswer) { - questionnaireItemViewItemDate?.format(dateInputFormat.patternWithoutDelimiters)?.let { + questionnaireItemViewItemDate?.format(dateInputFormat.pattern)?.let { DateInput(it, questionnaireItemViewItemDate) } ?: DateInput(display = draftAnswer ?: "", null) @@ -191,6 +191,7 @@ internal object DateTimePickerViewHolderFactory : QuestionnaireItemComposeViewHo parseStringToLocalDate = { str, pattern -> parseLocalDateOrNull(str, pattern) }, onDateInputEntry = { val (display, date) = it + println("Yellow: $display == $date") coroutineScope.launch { if (date != null) { val dateTime = 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 deleted file mode 100644 index 1f48b89d39..0000000000 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/compose/DateVisualTransformationTest.kt +++ /dev/null @@ -1,117 +0,0 @@ -/* - * 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)) - } -} From af187c54e265d65a37c913a1ece2472ef444487a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=E2=89=A1ZRS?= <12814349+LZRS@users.noreply.github.com> Date: Tue, 25 Nov 2025 16:11:53 +0300 Subject: [PATCH 10/10] Fix quick input change to empty not saving to QuestionnaireViewItem --- .../views/compose/DatePickerItem.kt | 32 ++++++++++++------- .../DateTimePickerViewHolderFactory.kt | 5 +-- 2 files changed, 24 insertions(+), 13 deletions(-) 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 index 155a8643d3..8915fcd0b5 100644 --- 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 @@ -90,18 +90,19 @@ internal fun DatePickerItem( var showDatePickerModal by remember { mutableStateOf(false) } var typingJob by remember { mutableStateOf(null) } - val postDelayedNewDateInput: (DateInput, Long) -> Unit = remember { - { newDateInput, delayInMillis -> - typingJob?.cancel() // Cancel previous debounce - typingJob = - coroutineScope.launch { - delay(delayInMillis) // Debounce delay - if (newDateInput != dateInput) { - onDateInputEntry(newDateInput) + val postDelayedNewDateInput: (DateInput, Long) -> Unit = + remember(dateInput) { + { newDateInput, delayInMillis -> + typingJob?.cancel() // Cancel previous debounce + typingJob = + coroutineScope.launch { + delay(delayInMillis) // Debounce delay + if (newDateInput != dateInput) { + onDateInputEntry(newDateInput) + } } - } + } } - } OutlinedTextField( value = dateInputDisplay, @@ -109,7 +110,16 @@ internal fun DatePickerItem( textFieldValue.text .takeIf { it.length <= dateInputFormat.pattern.length && - it.all { char -> char.isDigit() || char == dateInputFormat.delimiter } + it + .mapIndexed { index, char -> + char.isDigit() || + (index in + arrayOf( + dateInputFormat.delimiterFirstIndex, + dateInputFormat.delimiterLastIndex, + ) && char == dateInputFormat.delimiter) + } + .all { isAllowed -> isAllowed } } ?.let { val isDeletion = it.length < dateInputDisplay.text.length 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 a42370bc4d..f7354cf743 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 @@ -126,7 +126,9 @@ internal object DateTimePickerViewHolderFactory : QuestionnaireItemComposeViewHo questionnaireItemAnswerDateInMillis ?: MaterialDatePicker.todayInUtcMilliseconds() } val draftAnswer = - remember(questionnaireViewItem) { questionnaireViewItem.draftAnswer as? String } + remember(questionnaireViewItem.draftAnswer) { + questionnaireViewItem.draftAnswer as? String + } val dateInput = remember(dateInputFormat, questionnaireItemViewItemDate, draftAnswer) { questionnaireItemViewItemDate?.format(dateInputFormat.pattern)?.let { @@ -191,7 +193,6 @@ internal object DateTimePickerViewHolderFactory : QuestionnaireItemComposeViewHo parseStringToLocalDate = { str, pattern -> parseLocalDateOrNull(str, pattern) }, onDateInputEntry = { val (display, date) = it - println("Yellow: $display == $date") coroutineScope.launch { if (date != null) { val dateTime =