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") + } +}