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/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/time_picker_view.xml b/datacapture/src/main/res/layout/time_picker_view.xml
new file mode 100644
index 0000000000..2ad1cd5563
--- /dev/null
+++ b/datacapture/src/main/res/layout/time_picker_view.xml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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/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")
+ }
+}