diff --git a/catalog/src/main/assets/component_time_picker.json b/catalog/src/main/assets/component_time_picker.json new file mode 100644 index 0000000000..ac904d99c8 --- /dev/null +++ b/catalog/src/main/assets/component_time_picker.json @@ -0,0 +1,36 @@ +{ + "resourceType": "Questionnaire", + "item": [ + { + "linkId": "1", + "text": "Enter a time", + "type": "time", + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/entryFormat", + "valueString": "hh-mm" + } + ], + "item": [ + { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-displayCategory", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://hl7.org/fhir/questionnaire-display-category", + "code": "instructions" + } + ] + } + } + ], + "linkId": "1-most-recent", + "text": "Use keyboard entry or time picker", + "type": "display" + } + ] + } + ] +} \ No newline at end of file diff --git a/catalog/src/main/assets/component_time_picker_with_validation.json b/catalog/src/main/assets/component_time_picker_with_validation.json new file mode 100644 index 0000000000..198a8f5bea --- /dev/null +++ b/catalog/src/main/assets/component_time_picker_with_validation.json @@ -0,0 +1,37 @@ +{ + "resourceType": "Questionnaire", + "item": [ + { + "linkId": "1", + "text": "Enter a time", + "type": "time", + "required": true, + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/entryFormat", + "valueString": "hh-mm" + } + ], + "item": [ + { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-displayCategory", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://hl7.org/fhir/questionnaire-display-category", + "code": "instructions" + } + ] + } + } + ], + "linkId": "1-most-recent", + "text": "Use keyboard entry or time picker", + "type": "display" + } + ] + } + ] +} \ No newline at end of file diff --git a/catalog/src/main/java/com/google/android/fhir/catalog/ComponentListViewModel.kt b/catalog/src/main/java/com/google/android/fhir/catalog/ComponentListViewModel.kt index d9e4637ada..00f541e5c6 100644 --- a/catalog/src/main/java/com/google/android/fhir/catalog/ComponentListViewModel.kt +++ b/catalog/src/main/java/com/google/android/fhir/catalog/ComponentListViewModel.kt @@ -102,6 +102,12 @@ class ComponentListViewModel(application: Application, private val state: SavedS "component_date_picker.json", "component_date_picker_with_validation.json", ), + TIME_PICKER( + R.drawable.ic_timepicker, + R.string.component_name_time_picker, + "component_time_picker.json", + "component_time_picker_with_validation.json", + ), DATE_TIME_PICKER( R.drawable.ic_timepicker, R.string.component_name_date_time_picker, @@ -171,6 +177,7 @@ class ComponentListViewModel(application: Application, private val state: SavedS ViewItem.ComponentItem(Component.TEXT_FIELD), ViewItem.ComponentItem(Component.AUTO_COMPLETE), ViewItem.ComponentItem(Component.DATE_PICKER), + ViewItem.ComponentItem(Component.TIME_PICKER), ViewItem.ComponentItem(Component.DATE_TIME_PICKER), ViewItem.ComponentItem(Component.SLIDER), ViewItem.ComponentItem(Component.QUANTITY), diff --git a/catalog/src/main/res/values/strings.xml b/catalog/src/main/res/values/strings.xml index ae47067784..69b5f26fa8 100644 --- a/catalog/src/main/res/values/strings.xml +++ b/catalog/src/main/res/values/strings.xml @@ -28,6 +28,7 @@ Text field Auto Complete Date picker + Time picker DateTime picker Slider Quantity diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditAdapter.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditAdapter.kt index 90f43a53c6..6fc427eae2 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditAdapter.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditAdapter.kt @@ -47,6 +47,7 @@ import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemView import com.google.android.fhir.datacapture.views.factories.RadioGroupViewHolderFactory import com.google.android.fhir.datacapture.views.factories.RepeatedGroupHeaderItemViewHolder import com.google.android.fhir.datacapture.views.factories.SliderViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.TimePickerViewHolderFactory import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemType internal class QuestionnaireEditAdapter( @@ -103,6 +104,7 @@ internal class QuestionnaireEditAdapter( QuestionnaireViewHolderType.GROUP -> GroupViewHolderFactory QuestionnaireViewHolderType.BOOLEAN_TYPE_PICKER -> BooleanChoiceViewHolderFactory QuestionnaireViewHolderType.DATE_PICKER -> DatePickerViewHolderFactory + QuestionnaireViewHolderType.TIME_PICKER -> TimePickerViewHolderFactory QuestionnaireViewHolderType.DATE_TIME_PICKER -> DateTimePickerViewHolderFactory QuestionnaireViewHolderType.EDIT_TEXT_SINGLE_LINE -> EditTextSingleLineViewHolderFactory QuestionnaireViewHolderType.EDIT_TEXT_MULTI_LINE -> EditTextMultiLineViewHolderFactory @@ -223,6 +225,7 @@ internal class QuestionnaireEditAdapter( QuestionnaireItemType.GROUP -> QuestionnaireViewHolderType.GROUP QuestionnaireItemType.BOOLEAN -> QuestionnaireViewHolderType.BOOLEAN_TYPE_PICKER QuestionnaireItemType.DATE -> QuestionnaireViewHolderType.DATE_PICKER + QuestionnaireItemType.TIME -> QuestionnaireViewHolderType.TIME_PICKER QuestionnaireItemType.DATETIME -> QuestionnaireViewHolderType.DATE_TIME_PICKER QuestionnaireItemType.STRING -> getStringViewHolderType(questionnaireViewItem) QuestionnaireItemType.TEXT -> QuestionnaireViewHolderType.EDIT_TEXT_MULTI_LINE diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewHolderType.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewHolderType.kt index 5a64806841..d9442a652a 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewHolderType.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewHolderType.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 2023-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -45,6 +45,7 @@ enum class QuestionnaireViewHolderType(val value: Int) { SLIDER(15), PHONE_NUMBER(16), ATTACHMENT(17), + TIME_PICKER(18), ; companion object { diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt index 9d5aa26767..ad88f4bc6e 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt @@ -1183,7 +1183,7 @@ internal data class QuestionnairePage( ) internal val QuestionnairePagination.hasPreviousPage: Boolean - get() = pages.any { it.index < currentPageIndex && it.enabled } + get() = pages.any { it.index < currentPageIndex && it.enabled && !it.hidden } internal val QuestionnairePagination.hasNextPage: Boolean - get() = pages.any { it.index > currentPageIndex && it.enabled } + get() = pages.any { it.index > currentPageIndex && it.enabled && !it.hidden } 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 new file mode 100644 index 0000000000..91062b1de8 --- /dev/null +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/TimePickerViewHolderFactory.kt @@ -0,0 +1,151 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.datacapture.views.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 com.google.android.fhir.datacapture.R +import com.google.android.fhir.datacapture.extensions.getRequiredOrOptionalText +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 java.time.LocalTime +import java.time.format.DateTimeFormatter +import kotlinx.coroutines.launch +import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.TimeType + +object TimePickerViewHolderFactory : QuestionnaireItemViewHolderFactory(R.layout.time_picker_view) { + + override fun getQuestionnaireItemViewHolderDelegate() = + object : QuestionnaireItemViewHolderDelegate { + 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 + + 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 + } + 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() + } + } + } + .show(context.tryUnwrapContext()!!.supportFragmentManager, TAG) + } + + /** Set the answer in the [QuestionnaireResponse]. */ + private fun setQuestionnaireItemViewItemAnswer(localDateTime: LocalTime) = + context.lifecycleScope.launch { + questionnaireViewItem.setAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent() + .setValue(TimeType(localDateTime.format(DateTimeFormatter.ISO_TIME))), + ) + } + + private fun clearPreviousState() { + timeInputEditText.isEnabled = true + timeInputLayout.isEnabled = true + } + } + + private val TimeType.localTime + get() = + LocalTime.of( + hour, + minute, + second.toInt(), + ) +} diff --git a/datacapture/src/main/res/layout/edit_text_single_line_view.xml b/datacapture/src/main/res/layout/edit_text_single_line_view.xml index 7520fe616a..56bf4db06b 100644 --- a/datacapture/src/main/res/layout/edit_text_single_line_view.xml +++ b/datacapture/src/main/res/layout/edit_text_single_line_view.xml @@ -52,6 +52,7 @@ + + + + + + + + + + + + + diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewHolderTypeTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewHolderTypeTest.kt index 7b971501fb..04f45d42a6 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewHolderTypeTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewHolderTypeTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,7 +28,7 @@ import org.robolectric.annotation.Config class QuestionnaireViewHolderTypeTest { @Test fun size_shouldReturnNumberOfQuestionnaireViewHolderTypes() { - assertThat(QuestionnaireViewHolderType.values().size).isEqualTo(18) + assertThat(QuestionnaireViewHolderType.values().size).isEqualTo(19) } @Test diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelTest.kt index 51e00eaa1d..659429a95a 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelTest.kt @@ -1533,6 +1533,102 @@ class QuestionnaireViewModelTest { // Pagination // // // // ==================================================================== // + @Test + fun `should include all top level items as pages when any item has page extension`() = runTest { + val questionnaire = + Questionnaire().apply { + id = "a-questionnaire" + addItem( + QuestionnaireItemComponent().apply { + linkId = "page1-noExtension" + type = Questionnaire.QuestionnaireItemType.GROUP + addItem( + QuestionnaireItemComponent().apply { + linkId = "page1-1" + type = Questionnaire.QuestionnaireItemType.BOOLEAN + text = "Question on page 1" + }, + ) + }, + ) + addItem( + QuestionnaireItemComponent().apply { + linkId = "page2" + type = Questionnaire.QuestionnaireItemType.GROUP + addExtension(paginationExtension) + addItem( + QuestionnaireItemComponent().apply { + linkId = "page2-1" + type = Questionnaire.QuestionnaireItemType.BOOLEAN + text = "Question on page 2" + }, + ) + }, + ) + addItem( + QuestionnaireItemComponent().apply { + linkId = "page3-noExtension" + type = Questionnaire.QuestionnaireItemType.GROUP + addItem( + QuestionnaireItemComponent().apply { + linkId = "page3-1" + type = Questionnaire.QuestionnaireItemType.BOOLEAN + text = "Question on page 3" + }, + ) + }, + ) + addItem( + QuestionnaireItemComponent().apply { + linkId = "page4-noExtension-hidden" + addExtension(hiddenExtension) + type = Questionnaire.QuestionnaireItemType.GROUP + addItem( + QuestionnaireItemComponent().apply { + linkId = "page4-1" + type = Questionnaire.QuestionnaireItemType.BOOLEAN + text = "Question on page 4" + }, + ) + }, + ) + addItem( + QuestionnaireItemComponent().apply { + linkId = "page5" + type = Questionnaire.QuestionnaireItemType.GROUP + addExtension(paginationExtension) + addItem( + QuestionnaireItemComponent().apply { + linkId = "page5-1" + type = Questionnaire.QuestionnaireItemType.BOOLEAN + text = "Question on page 5" + }, + ) + }, + ) + } + + val viewModel = createQuestionnaireViewModel(questionnaire) + viewModel.runViewModelBlocking { + assertThat( + (viewModel.questionnaireStateFlow.value.displayMode as DisplayMode.EditMode).pagination, + ) + .isEqualTo( + QuestionnairePagination( + isPaginated = true, + pages = + listOf( + QuestionnairePage(0, enabled = true, hidden = false), + QuestionnairePage(1, enabled = true, hidden = false), + QuestionnairePage(2, enabled = true, hidden = false), + QuestionnairePage(3, enabled = true, hidden = true), + QuestionnairePage(4, enabled = true, hidden = false), + ), + currentPageIndex = 0, + ), + ) + } + } @Test fun `should show current page`() = runTest { @@ -1830,8 +1926,11 @@ class QuestionnaireViewModelTest { } val viewModel = createQuestionnaireViewModel(questionnaire) viewModel.runViewModelBlocking { + val questionnaireStatePagination = + (viewModel.questionnaireStateFlow.value.displayMode as DisplayMode.EditMode).pagination + assertThat( - (viewModel.questionnaireStateFlow.value.displayMode as DisplayMode.EditMode).pagination, + questionnaireStatePagination, ) .isEqualTo( QuestionnairePagination( @@ -1845,6 +1944,83 @@ class QuestionnaireViewModelTest { currentPageIndex = 1, ), ) + + assertThat(questionnaireStatePagination.hasPreviousPage).isFalse() + } + } + + @Test + fun `should skip last page if it is hidden`() = runTest { + val questionnaire = + Questionnaire().apply { + id = "a-questionnaire" + addItem( + QuestionnaireItemComponent().apply { + linkId = "page1" + type = Questionnaire.QuestionnaireItemType.GROUP + addExtension(paginationExtension) + addItem( + QuestionnaireItemComponent().apply { + linkId = "page1-1" + type = Questionnaire.QuestionnaireItemType.BOOLEAN + text = "Question on page 1" + }, + ) + }, + ) + addItem( + QuestionnaireItemComponent().apply { + linkId = "page2" + type = Questionnaire.QuestionnaireItemType.GROUP + addExtension(paginationExtension) + addItem( + QuestionnaireItemComponent().apply { + linkId = "page2-1" + type = Questionnaire.QuestionnaireItemType.BOOLEAN + text = "Question on page 2" + }, + ) + }, + ) + addItem( + QuestionnaireItemComponent().apply { + linkId = "page3" + type = Questionnaire.QuestionnaireItemType.GROUP + addExtension(paginationExtension) + addExtension(hiddenExtension) + addItem( + QuestionnaireItemComponent().apply { + linkId = "page3-1" + type = Questionnaire.QuestionnaireItemType.BOOLEAN + text = "Question on page 3" + }, + ) + }, + ) + } + val viewModel = createQuestionnaireViewModel(questionnaire) + viewModel.runViewModelBlocking { + viewModel.goToNextPage() + val questionnaireStatePagination = + (viewModel.questionnaireStateFlow.value.displayMode as DisplayMode.EditMode).pagination + + assertThat( + questionnaireStatePagination, + ) + .isEqualTo( + QuestionnairePagination( + isPaginated = true, + pages = + listOf( + QuestionnairePage(0, enabled = true, hidden = false), + QuestionnairePage(1, enabled = true, hidden = false), + QuestionnairePage(2, enabled = true, hidden = true), + ), + currentPageIndex = 1, + ), + ) + + assertThat(questionnaireStatePagination.hasNextPage).isFalse() } } diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/TimePickerViewHolderFactoryTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/TimePickerViewHolderFactoryTest.kt new file mode 100644 index 0000000000..90e4d5bc3f --- /dev/null +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/TimePickerViewHolderFactoryTest.kt @@ -0,0 +1,132 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.datacapture.views + +import android.widget.FrameLayout +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import com.google.android.fhir.datacapture.R +import com.google.android.fhir.datacapture.validation.NotValidated +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.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.shadows.ShadowSettings + +@RunWith(RobolectricTestRunner::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) + + private val QuestionnaireItemViewHolder.timeInputView: TextView + get() { + return itemView.findViewById(R.id.text_input_edit_text) + } + + @Test + fun shouldSetQuestionHeader() { + viewHolder.bind( + QuestionnaireViewItem( + Questionnaire.QuestionnaireItemComponent().apply { text = "Question?" }, + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + assertThat(viewHolder.itemView.findViewById(R.id.question).text.toString()) + .isEqualTo("Question?") + } + + @Test + fun shouldSetEmptyTimeInput() { + viewHolder.bind( + QuestionnaireViewItem( + Questionnaire.QuestionnaireItemComponent().apply { text = "Question?" }, + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + assertThat(viewHolder.timeInputView.text.toString()).isEqualTo("") + } + + @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") + } + + @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")), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + assertThat(viewHolder.timeInputView.text.toString()).isEqualTo("10:10 PM") + } + + @Test + fun `should show time when set time format is 24 hrs`() { + ShadowSettings.set24HourTimeFormat(true) + viewHolder.bind( + QuestionnaireViewItem( + Questionnaire.QuestionnaireItemComponent().apply { text = "Question?" }, + QuestionnaireResponse.QuestionnaireResponseItemComponent() + .addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent() + .setValue(TimeType("22:10")), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + assertThat(viewHolder.timeInputView.text.toString()).isEqualTo("22:10") + } +} diff --git a/demo/src/main/java/com/google/android/fhir/demo/CrudOperationFragment.kt b/demo/src/main/java/com/google/android/fhir/demo/CrudOperationFragment.kt new file mode 100644 index 0000000000..2694044bc8 --- /dev/null +++ b/demo/src/main/java/com/google/android/fhir/demo/CrudOperationFragment.kt @@ -0,0 +1,338 @@ +/* + * Copyright 2024-2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * 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.demo + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.CheckBox +import android.widget.EditText +import android.widget.RadioGroup +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.NavHostFragment +import com.google.android.fhir.demo.helpers.PatientCreationHelper +import com.google.android.material.tabs.TabLayout +import kotlinx.coroutines.launch +import org.hl7.fhir.r4.model.Enumerations + +class CrudOperationFragment : Fragment() { + private val crudOperationViewModel: CrudOperationViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + return inflater.inflate(R.layout.fragment_crud_layout, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setUpActionBar() + setHasOptionsMenu(true) + setupUiOnScreenLaunch() + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + crudOperationViewModel.patientUiState.collect { patientUiState -> + patientUiState?.let { + when (it.operationType) { + OperationType.CREATE -> { + Toast.makeText(requireContext(), "Patient is saved", Toast.LENGTH_SHORT).show() + } + OperationType.READ -> displayPatientDetails(it) + OperationType.UPDATE -> { + Toast.makeText(requireContext(), "Patient is updated", Toast.LENGTH_SHORT).show() + } + OperationType.DELETE -> { + // Reset the page as the patient has been deleted. + clearUiFieldValues() + configureFieldsForOperation(OperationType.DELETE) + Toast.makeText(requireContext(), "Patient is deleted", Toast.LENGTH_SHORT).show() + } + } + } + } + } + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + android.R.id.home -> { + NavHostFragment.findNavController(this).navigateUp() + true + } + else -> false + } + } + + private fun setUpActionBar() { + (requireActivity() as AppCompatActivity).supportActionBar?.apply { + title = requireContext().getString(R.string.crud_operations) + setDisplayHomeAsUpEnabled(true) + } + } + + private fun setupUiOnScreenLaunch() { + setupTabLayoutChangeListener() + selectTab(TAB_CREATE) + setupUiForCrudOperation(OperationType.CREATE) + + requireView().findViewById