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