diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 594599d64e..61d8afb074 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -101,7 +101,6 @@ object Dependencies { const val mockWebServer = "com.squareup.okhttp3:mockwebserver:${Versions.http}" const val jsonToolsPatch = "com.github.java-json-tools:json-patch:${Versions.jsonToolsPatch}" - const val material = "com.google.android.material:material:${Versions.material}" const val sqlcipher = "net.zetetic:android-database-sqlcipher:${Versions.sqlcipher}" const val timber = "com.jakewharton.timber:timber:${Versions.timber}" const val woodstox = "com.fasterxml.woodstox:woodstox-core:${Versions.woodstox}" @@ -143,7 +142,6 @@ object Dependencies { const val jsonToolsPatch = "1.13" const val jsonAssert = "1.5.1" - const val material = "1.9.0" const val retrofit = "2.9.0" const val gsonConverter = "2.1.0" const val sqlcipher = "4.5.4" diff --git a/buildSrc/src/main/kotlin/Releases.kt b/buildSrc/src/main/kotlin/Releases.kt index dae9dfcc8b..4a5a54d064 100644 --- a/buildSrc/src/main/kotlin/Releases.kt +++ b/buildSrc/src/main/kotlin/Releases.kt @@ -48,7 +48,7 @@ object Releases { object Engine : LibraryArtifact { override val artifactId = "engine" - override val version = "1.0.0" + override val version = "1.1.0" override val name = "Android FHIR Engine Library" } @@ -98,6 +98,12 @@ object Releases { const val versionCode = 1 const val versionName = "1.0" } + + object WorkflowDemo { + const val applicationId = "com.google.android.fhir.workflow.demo" + const val versionCode = 1 + const val versionName = "1.0" + } } fun Project.publishArtifact(artifact: LibraryArtifact) { diff --git a/catalog/build.gradle.kts b/catalog/build.gradle.kts index ff0693fd64..a89e6d68e6 100644 --- a/catalog/build.gradle.kts +++ b/catalog/build.gradle.kts @@ -46,7 +46,6 @@ dependencies { coreLibraryDesugaring(Dependencies.desugarJdkLibs) - implementation(Dependencies.material) implementation(libs.androidx.appcompat) implementation(libs.androidx.constraintlayout) implementation(libs.androidx.core) @@ -54,6 +53,7 @@ dependencies { implementation(libs.androidx.navigation.fragment) implementation(libs.androidx.navigation.ui) implementation(libs.kotlin.stdlib) + implementation(libs.material) implementation(project(path = ":datacapture")) implementation(project(path = ":engine")) diff --git a/catalog/src/main/assets/component_modal.json b/catalog/src/main/assets/component_modal.json index 99c24c2623..54b8dc9222 100644 --- a/catalog/src/main/assets/component_modal.json +++ b/catalog/src/main/assets/component_modal.json @@ -2,6 +2,24 @@ "resourceType": "Questionnaire", "item": [ { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://hl7.org/fhir/questionnaire-item-control", + "code": "check-box", + "display": "Check Box" + } + ], + "text": "Check box" + } + }, + { + "url": "https://github.com/google/android-fhir/StructureDefinition/dialog" + } + ], "linkId": "1.1", "type": "choice", "repeats": true, @@ -44,48 +62,6 @@ "code": "diarrhoea", "display": "Diarrhoea" } - }, - { - "valueCoding": { - "code": "fever", - "display": "Fever" - } - }, - { - "valueCoding": { - "code": "injury", - "display": "Injury" - } - }, - { - "valueCoding": { - "code": "jaundice", - "display": "Jaundice" - } - }, - { - "valueCoding": { - "code": "mental-health", - "display": "Mental health" - } - }, - { - "valueCoding": { - "code": "nausea", - "display": "Nausea" - } - }, - { - "valueCoding": { - "code": "pain", - "display": "Pain" - } - }, - { - "valueCoding": { - "code": "bleeding", - "display": "Bleeding" - } } ] } 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/codelabs/datacapture/README.md b/codelabs/datacapture/README.md index e9a7f9e196..af44e32e18 100644 --- a/codelabs/datacapture/README.md +++ b/codelabs/datacapture/README.md @@ -76,8 +76,8 @@ of the `app/build.gradle.kts` file of your project: dependencies { // ... - implementation("com.google.android.fhir:data-capture:1.0.0") - implementation("androidx.fragment:fragment-ktx:1.5.5") + implementation("com.google.android.fhir:data-capture:1.2.0") + implementation("androidx.fragment:fragment-ktx:1.6.0") } ``` @@ -150,11 +150,11 @@ Open `MainActivity.kt` and add the following code to the `MainActivity` class: ```kotlin // Step 2: Configure a QuestionnaireFragment -val questionnaireJsonString = getStringFromAssets("questionnaire.json") +questionnaireJsonString = getStringFromAssets("questionnaire.json") + +val questionnaireFragment = + QuestionnaireFragment.builder().setQuestionnaire(questionnaireJsonString!!).build() -val questionnaireParams = bundleOf( -QuestionnaireFragment.EXTRA_QUESTIONNAIRE_JSON_STRING to questionnaireJsonString -) ``` ### Step 3: Add the QuestionnaireFragment to the FragmentContainerView @@ -168,10 +168,10 @@ Add the following code to the `MainActivity` class: ```kotlin // Step 3: Add the QuestionnaireFragment to the FragmentContainerView if (savedInstanceState == null) { - supportFragmentManager.commit { - setReorderingAllowed(true) - add(R.id.fragment_container_view, args = questionnaireParams) - } + supportFragmentManager.commit { + setReorderingAllowed(true) + add(R.id.fragment_container_view, questionnaireFragment) + } } // Submit button callback supportFragmentManager.setFragmentResultListener( diff --git a/codelabs/engine/README.md b/codelabs/engine/README.md index 67bfd6fabc..f0fc5b8ddf 100644 --- a/codelabs/engine/README.md +++ b/codelabs/engine/README.md @@ -125,7 +125,7 @@ file of your project: dependencies { // ... - implementation("com.google.android.fhir:engine:1.0.0") + implementation("com.google.android.fhir:engine:1.1.0") } ``` @@ -257,7 +257,13 @@ outlined below will guide you through the process. override fun getFhirEngine() = FhirApplication.fhirEngine(applicationContext) - override fun getUploadStrategy() = UploadStrategy.AllChangesSquashedBundlePut + override fun getUploadStrategy() = + UploadStrategy.forBundleRequest( + methodForCreate = HttpCreateMethod.PUT, + methodForUpdate = HttpUpdateMethod.PATCH, + squash = true, + bundleSize = 500, + ) } ``` diff --git a/codelabs/engine/app/build.gradle.kts b/codelabs/engine/app/build.gradle.kts index 23c48e875f..b682babd86 100644 --- a/codelabs/engine/app/build.gradle.kts +++ b/codelabs/engine/app/build.gradle.kts @@ -49,6 +49,6 @@ dependencies { androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") - implementation("com.google.android.fhir:engine:1.0.0") + implementation("com.google.android.fhir:engine:1.1.0") implementation("androidx.fragment:fragment-ktx:1.8.3") } diff --git a/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientItemViewHolder.kt b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientItemViewHolder.kt index 409441019f..6b115142a0 100644 --- a/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientItemViewHolder.kt +++ b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientItemViewHolder.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. @@ -30,8 +30,10 @@ class PatientItemViewHolder(binding: PatientListItemViewBinding) : fun bind(patientItem: Patient) { nameTextView.text = - patientItem.name.first().let { it.given.joinToString(separator = " ") + " " + it.family } - genderTextView.text = patientItem.gender.display + patientItem.name.firstOrNull()?.let { + it.given.joinToString(separator = " ") + " " + it.family + } + genderTextView.text = patientItem.gender?.display cityTextView.text = patientItem.address.singleOrNull()?.city } } diff --git a/contrib/barcode/build.gradle.kts b/contrib/barcode/build.gradle.kts index 426cd37624..d731f7819b 100644 --- a/contrib/barcode/build.gradle.kts +++ b/contrib/barcode/build.gradle.kts @@ -70,11 +70,11 @@ dependencies { implementation(Dependencies.Mlkit.barcodeScanning) implementation(Dependencies.Mlkit.objectDetection) implementation(Dependencies.Mlkit.objectDetectionCustom) - implementation(Dependencies.material) implementation(Dependencies.timber) implementation(libs.androidx.appcompat) implementation(libs.androidx.core) implementation(libs.androidx.fragment) + implementation(libs.material) testImplementation(Dependencies.mockitoInline) testImplementation(Dependencies.mockitoKotlin) diff --git a/contrib/locationwidget/build.gradle.kts b/contrib/locationwidget/build.gradle.kts index dc52ab95a7..0b2148f79c 100644 --- a/contrib/locationwidget/build.gradle.kts +++ b/contrib/locationwidget/build.gradle.kts @@ -65,12 +65,12 @@ dependencies { implementation(project(":datacapture")) implementation(Dependencies.playServicesLocation) - implementation(Dependencies.material) implementation(Dependencies.timber) implementation(libs.androidx.appcompat) implementation(libs.androidx.core) implementation(libs.androidx.fragment) implementation(libs.kotlinx.coroutines.playservices) + implementation(libs.material) coreLibraryDesugaring(Dependencies.desugarJdkLibs) diff --git a/contrib/locationwidget/src/androidTest/java/com/google/android/fhir/datacapture/contrib/views/locationwidget/LocationGpsCoordinateViewHolderFactoryInstrumentedTest.kt b/contrib/locationwidget/src/androidTest/java/com/google/android/fhir/datacapture/contrib/views/locationwidget/LocationGpsCoordinateViewHolderFactoryInstrumentedTest.kt index b91eec759f..bdf795eac5 100644 --- a/contrib/locationwidget/src/androidTest/java/com/google/android/fhir/datacapture/contrib/views/locationwidget/LocationGpsCoordinateViewHolderFactoryInstrumentedTest.kt +++ b/contrib/locationwidget/src/androidTest/java/com/google/android/fhir/datacapture/contrib/views/locationwidget/LocationGpsCoordinateViewHolderFactoryInstrumentedTest.kt @@ -27,6 +27,7 @@ import androidx.core.view.isVisible import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.google.common.truth.Truth.assertThat +import org.hl7.fhir.r4.model.Questionnaire import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -55,4 +56,30 @@ class LocationGpsCoordinateViewHolderFactoryInstrumentedTest { ) .isEqualTo(InputType.TYPE_NULL) } + + @Test + fun matcherShouldReturnTrueForOriginalGpsCoordinateUrl() { + val questionnaireItem = Questionnaire.QuestionnaireItemComponent() + questionnaireItem.addExtension( + LocationGpsCoordinateViewHolderFactory.PRIMARY_GPS_COORDINATE_EXTENSION_URL, + null, + ) + assertThat(LocationGpsCoordinateViewHolderFactory.matcher(questionnaireItem)).isTrue() + } + + @Test + fun matcherShouldReturnTrueForOldGpsCoordinateUrl() { + val questionnaireItem = Questionnaire.QuestionnaireItemComponent() + questionnaireItem.addExtension( + LocationGpsCoordinateViewHolderFactory.GPS_COORDINATE_EXTENSION_URL, + null, + ) + assertThat(LocationGpsCoordinateViewHolderFactory.matcher(questionnaireItem)).isTrue() + } + + @Test + fun matcherShouldReturnFalseForNoGpsCoordinateUrl() { + val questionnaireItem = Questionnaire.QuestionnaireItemComponent() + assertThat(LocationGpsCoordinateViewHolderFactory.matcher(questionnaireItem)).isFalse() + } } diff --git a/contrib/locationwidget/src/main/java/com/google/android/fhir/datacapture/contrib/views/locationwidget/LocationGpsCoordinateViewHolderFactory.kt b/contrib/locationwidget/src/main/java/com/google/android/fhir/datacapture/contrib/views/locationwidget/LocationGpsCoordinateViewHolderFactory.kt index 18769f6166..a12b87dc69 100644 --- a/contrib/locationwidget/src/main/java/com/google/android/fhir/datacapture/contrib/views/locationwidget/LocationGpsCoordinateViewHolderFactory.kt +++ b/contrib/locationwidget/src/main/java/com/google/android/fhir/datacapture/contrib/views/locationwidget/LocationGpsCoordinateViewHolderFactory.kt @@ -59,8 +59,11 @@ object LocationGpsCoordinateViewHolderFactory : header.context.tryUnwrapContext()?.apply { val gpsCoordinateExtensionValue = questionnaireViewItem.questionnaireItem - .getExtensionByUrl(GPS_COORDINATE_EXTENSION_URL) - .value as StringType + .getExtensionByUrl(PRIMARY_GPS_COORDINATE_EXTENSION_URL) + ?.value as? StringType + ?: questionnaireViewItem.questionnaireItem + .getExtensionByUrl(GPS_COORDINATE_EXTENSION_URL) + .value as StringType when (gpsCoordinateExtensionValue.valueAsString) { GPS_COORDINATE_EXTENSION_VALUE_LATITUDE -> { supportFragmentManager.setFragmentResultListener( @@ -148,9 +151,12 @@ object LocationGpsCoordinateViewHolderFactory : } fun matcher(questionnaireItem: Questionnaire.QuestionnaireItemComponent): Boolean { - return questionnaireItem.hasExtension(GPS_COORDINATE_EXTENSION_URL) + return questionnaireItem.hasExtension(PRIMARY_GPS_COORDINATE_EXTENSION_URL) || + questionnaireItem.hasExtension(GPS_COORDINATE_EXTENSION_URL) } + const val PRIMARY_GPS_COORDINATE_EXTENSION_URL = + "https://github.com/google/android-fhir/StructureDefinition/gps-coordinate" const val GPS_COORDINATE_EXTENSION_URL = "gps-coordinate" const val GPS_COORDINATE_EXTENSION_VALUE_LATITUDE = "latitude" const val GPS_COORDINATE_EXTENSION_VALUE_LONGITUDE = "longitude" diff --git a/datacapture/build.gradle.kts b/datacapture/build.gradle.kts index 34d81ab04c..b3c1e12d92 100644 --- a/datacapture/build.gradle.kts +++ b/datacapture/build.gradle.kts @@ -90,7 +90,6 @@ dependencies { exclude(module = "commons-logging") exclude(module = "httpclient") } - implementation(Dependencies.material) implementation(Dependencies.timber) implementation(libs.android.fhir.common) implementation(libs.androidx.appcompat) @@ -101,6 +100,7 @@ dependencies { implementation(libs.glide) implementation(libs.kotlin.stdlib) implementation(libs.kotlinx.coroutines.core) + implementation(libs.material) testImplementation(Dependencies.mockitoInline) testImplementation(Dependencies.mockitoKotlin) 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 2b2ea328cf..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 @@ -25,6 +25,7 @@ import androidx.recyclerview.widget.RecyclerView import com.google.android.fhir.datacapture.contrib.views.PhoneNumberViewHolderFactory import com.google.android.fhir.datacapture.extensions.inflate import com.google.android.fhir.datacapture.extensions.itemControl +import com.google.android.fhir.datacapture.extensions.shouldUseDialog import com.google.android.fhir.datacapture.views.NavigationViewHolder import com.google.android.fhir.datacapture.views.QuestionnaireViewItem import com.google.android.fhir.datacapture.views.factories.AttachmentViewHolderFactory @@ -46,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( @@ -102,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 @@ -222,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 @@ -241,8 +245,11 @@ internal class QuestionnaireEditAdapter( ): QuestionnaireViewHolderType { val questionnaireItem = questionnaireViewItem.questionnaireItem - // Use the view type that the client wants if they specified an itemControl - return questionnaireItem.itemControl?.viewHolderType + // Use the view type that the client wants if they specified an itemControl or dialog extension + return when { + questionnaireItem.shouldUseDialog -> QuestionnaireViewHolderType.DIALOG_SELECT + else -> questionnaireItem.itemControl?.viewHolderType + } // Otherwise, choose a sensible UI element automatically ?: run { val numOptions = questionnaireViewItem.enabledAnswerOptions.size 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 40267c3da0..40572b8c63 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 @@ -46,6 +46,7 @@ import com.google.android.fhir.datacapture.extensions.isHelpCode import com.google.android.fhir.datacapture.extensions.isHidden import com.google.android.fhir.datacapture.extensions.isPaginated import com.google.android.fhir.datacapture.extensions.isRepeatedGroup +import com.google.android.fhir.datacapture.extensions.launchTimestamp import com.google.android.fhir.datacapture.extensions.localizedTextSpanned import com.google.android.fhir.datacapture.extensions.maxValue import com.google.android.fhir.datacapture.extensions.maxValueCqfCalculatedValueExpression @@ -68,6 +69,7 @@ import com.google.android.fhir.datacapture.validation.Valid import com.google.android.fhir.datacapture.validation.ValidationResult import com.google.android.fhir.datacapture.views.QuestionTextConfiguration import com.google.android.fhir.datacapture.views.QuestionnaireViewItem +import java.util.Date import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -79,6 +81,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.withIndex import kotlinx.coroutines.launch +import org.hl7.fhir.r4.model.DateTimeType import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent import org.hl7.fhir.r4.model.QuestionnaireResponse @@ -165,6 +168,8 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat .forEach { questionnaireResponse.addItem(it.createQuestionnaireResponseItem()) } } } + // Add extension for questionnaire launch time stamp + questionnaireResponse.launchTimestamp = DateTimeType(Date()) questionnaireResponse.packRepeatedGroups(questionnaire) } @@ -500,6 +505,8 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat ) .map { it.copy() } unpackRepeatedGroups(this@QuestionnaireViewModel.questionnaire) + // Use authored as a submission time stamp + authored = Date() } } @@ -1233,7 +1240,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/extensions/MoreHeaderViews.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreHeaderViews.kt index fd4690ea26..84ffef9247 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreHeaderViews.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreHeaderViews.kt @@ -19,6 +19,7 @@ package com.google.android.fhir.datacapture.extensions import android.content.Context import android.text.SpannableStringBuilder import android.text.Spanned +import android.text.method.LinkMovementMethod import android.view.View.GONE import android.view.View.VISIBLE import android.widget.Button @@ -82,7 +83,10 @@ fun initHelpViews( } } } - helpTextView.updateTextAndVisibility(questionnaireItem.localizedHelpSpanned) + helpTextView.apply { + updateTextAndVisibility(questionnaireItem.localizedHelpSpanned) + movementMethod = LinkMovementMethod.getInstance() + } } /** diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt index 800660db9f..94c2e56738 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt @@ -61,6 +61,9 @@ internal const val EXTENSION_ITEM_CONTROL_URL_ANDROID_FHIR = internal const val EXTENSION_ITEM_CONTROL_SYSTEM_ANDROID_FHIR = "https://github.com/google/android-fhir/questionnaire-item-control" +internal const val EXTENSION_DIALOG_URL_ANDROID_FHIR = + "https://github.com/google/android-fhir/StructureDefinition/dialog" + internal enum class StyleUrl(val url: String) { BASE("https://github.com/google/android-fhir/tree/master/datacapture/android-style"), PREFIX_TEXT_VIEW("prefix_text_view"), @@ -237,6 +240,15 @@ val QuestionnaireItemComponent.itemControlCode: String? val QuestionnaireItemComponent.itemControl: ItemControlTypes? get() = ItemControlTypes.values().firstOrNull { it.extensionCode == itemControlCode } +private val QuestionnaireItemComponent.hasDialogExtension: Boolean + get() = this.extension.any { it.url == EXTENSION_DIALOG_URL_ANDROID_FHIR } + +val QuestionnaireItemComponent.shouldUseDialog: Boolean + get() = + this.hasDialogExtension && + (this.itemControl?.viewHolderType == QuestionnaireViewHolderType.CHECK_BOX_GROUP || + this.itemControl?.viewHolderType == QuestionnaireViewHolderType.RADIO_GROUP) + /** * The desired orientation for the list of choices. * diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireResponses.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireResponses.kt index fa0f4c545d..e560798035 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireResponses.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireResponses.kt @@ -16,9 +16,14 @@ package com.google.android.fhir.datacapture.extensions +import org.hl7.fhir.r4.model.DateTimeType +import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse +internal const val EXTENSION_LAST_LAUNCHED_TIMESTAMP: String = + "http://github.com/google-android/questionnaire-lastLaunched-timestamp" + /** Pre-order list of all questionnaire response items in the questionnaire. */ val QuestionnaireResponse.allItems: List get() = item.flatMap { it.descendant } @@ -67,16 +72,8 @@ private fun List.packR val questionnaireItem = questionnaireItems.single() - questionnaireResponseItems.forEach { it -> - if (questionnaireItem.type == Questionnaire.QuestionnaireItemType.GROUP) { - if (questionnaireItem.repeats) { - it.answer.forEach { it.item = it.item.packRepeatedGroups(questionnaireItem.item) } - } else { - it.item = it.item.packRepeatedGroups(questionnaireItem.item) - } - } else { - it.answer.forEach { it.item = it.item.packRepeatedGroups(questionnaireItem.item) } - } + questionnaireResponseItems.forEach { + it.item = it.item.packRepeatedGroups(questionnaireItem.item) } if ( @@ -154,3 +151,20 @@ private fun unpackRepeatedGroups( listOf(questionnaireResponseItem) } } + +/** + * Adds a launch timestamp extension to the Questionnaire Response. If the extension @see + * EXTENSION_LAUNCH_TIMESTAMP already exists, it updates its value; otherwise, it adds a new one. + */ +internal var QuestionnaireResponse.launchTimestamp: DateTimeType? + get() { + val extension = this.extension.firstOrNull { it.url == EXTENSION_LAST_LAUNCHED_TIMESTAMP } + return extension?.value as? DateTimeType + } + set(value) { + extension.find { it.url == EXTENSION_LAST_LAUNCHED_TIMESTAMP }?.setValue(value) + ?: run { + // Add a new extension if none exists + extension.add(Extension(EXTENSION_LAST_LAUNCHED_TIMESTAMP, value)) + } + } diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt index bf6a2b00bd..c51bfb8413 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt @@ -17,6 +17,7 @@ package com.google.android.fhir.datacapture.extensions import android.content.Context +import android.text.Spanned import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.views.factories.localDate import com.google.android.fhir.datacapture.views.factories.localTime @@ -57,6 +58,8 @@ fun Type.asStringValue(): String { fun Type.displayString(context: Context): String = getDisplayString(this, context) ?: context.getString(R.string.not_answered) +fun Type.displayStringSpanned(context: Context): Spanned = displayString(context).toSpanned() + /** Returns value as string depending on the [Type] of element. */ fun Type.getValueAsString(context: Context): String = getValueString(this) ?: context.getString(R.string.not_answered) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/GroupHeaderView.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/GroupHeaderView.kt index da85cbac4e..e2d1872bc4 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/GroupHeaderView.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/GroupHeaderView.kt @@ -17,6 +17,7 @@ package com.google.android.fhir.datacapture.views import android.content.Context +import android.text.method.LinkMovementMethod import android.util.AttributeSet import android.view.LayoutInflater import android.widget.LinearLayout @@ -55,11 +56,17 @@ class GroupHeaderView(context: Context, attrs: AttributeSet?) : LinearLayout(con helpCardStateChangedCallback = questionnaireViewItem.helpCardStateChangedCallback, ) prefix.updateTextAndVisibility(questionnaireViewItem.questionnaireItem.localizedPrefixSpanned) - // CQF expression takes precedence over static question text - question.updateTextAndVisibility(questionnaireViewItem.questionText) - hint.updateTextAndVisibility( - questionnaireViewItem.enabledDisplayItems.getLocalizedInstructionsSpanned(), - ) + question.apply { + // CQF expression takes precedence over static question text + updateTextAndVisibility(questionnaireViewItem.questionText) + movementMethod = LinkMovementMethod.getInstance() + } + hint.apply { + updateTextAndVisibility( + questionnaireViewItem.enabledDisplayItems.getLocalizedInstructionsSpanned(), + ) + movementMethod = LinkMovementMethod.getInstance() + } visibility = getHeaderViewVisibility(prefix, question, hint) applyCustomOrDefaultStyle( questionnaireViewItem.questionnaireItem, diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/HeaderView.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/HeaderView.kt index 7e5e77231d..b723190448 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/HeaderView.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/HeaderView.kt @@ -17,6 +17,7 @@ package com.google.android.fhir.datacapture.views import android.content.Context +import android.text.method.LinkMovementMethod import android.util.AttributeSet import android.view.LayoutInflater import android.widget.LinearLayout @@ -55,13 +56,17 @@ class HeaderView(context: Context, attrs: AttributeSet?) : LinearLayout(context, helpCardStateChangedCallback = questionnaireViewItem.helpCardStateChangedCallback, ) prefix.updateTextAndVisibility(questionnaireViewItem.questionnaireItem.localizedPrefixSpanned) - // CQF expression takes precedence over static question text - question.updateTextAndVisibility( - appendAsteriskToQuestionText(question.context, questionnaireViewItem), - ) - hint.updateTextAndVisibility( - questionnaireViewItem.enabledDisplayItems.getLocalizedInstructionsSpanned(), - ) + question.apply { + // CQF expression takes precedence over static question text + updateTextAndVisibility(appendAsteriskToQuestionText(question.context, questionnaireViewItem)) + movementMethod = LinkMovementMethod.getInstance() + } + hint.apply { + updateTextAndVisibility( + questionnaireViewItem.enabledDisplayItems.getLocalizedInstructionsSpanned(), + ) + movementMethod = LinkMovementMethod.getInstance() + } // Make the entire view GONE if there is nothing to show. This is to avoid an empty row in the // questionnaire. visibility = getHeaderViewVisibility(prefix, question, hint) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/OptionSelectDialogFragment.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/OptionSelectDialogFragment.kt index 4fb0238ca0..ab40dc3eb1 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/OptionSelectDialogFragment.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/OptionSelectDialogFragment.kt @@ -41,6 +41,7 @@ import androidx.recyclerview.widget.RecyclerView import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.extensions.itemAnswerOptionImage import com.google.android.fhir.datacapture.extensions.optionExclusive +import com.google.android.fhir.datacapture.extensions.toSpanned import com.google.android.fhir.datacapture.views.factories.OptionSelectOption import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemDialogSelectViewModel import com.google.android.fhir.datacapture.views.factories.SelectedOptions @@ -208,7 +209,7 @@ private class OptionSelectAdapter(val multiSelectEnabled: Boolean) : } else { (holder as OptionSelectViewHolder.OptionSingle).radioButton } - compoundButton.text = item.option.displayString + compoundButton.text = item.option.displayString.toSpanned() compoundButton.setCompoundDrawablesRelative( item.option.item.itemAnswerOptionImage(compoundButton.context), null, diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/CheckBoxGroupViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/CheckBoxGroupViewHolderFactory.kt index 6e41ca6b9f..179eb7da79 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/CheckBoxGroupViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/CheckBoxGroupViewHolderFactory.kt @@ -27,7 +27,7 @@ import androidx.lifecycle.lifecycleScope import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.extensions.ChoiceOrientationTypes import com.google.android.fhir.datacapture.extensions.choiceOrientation -import com.google.android.fhir.datacapture.extensions.displayString +import com.google.android.fhir.datacapture.extensions.displayStringSpanned import com.google.android.fhir.datacapture.extensions.itemAnswerOptionImage import com.google.android.fhir.datacapture.extensions.optionExclusive import com.google.android.fhir.datacapture.extensions.tryUnwrapContext @@ -104,7 +104,7 @@ internal object CheckBoxGroupViewHolderFactory : val checkbox = checkboxLayout.findViewById(R.id.check_box).apply { id = viewId - text = answerOption.value.displayString(header.context) + text = answerOption.value.displayStringSpanned(header.context) setCompoundDrawablesRelative( answerOption.itemAnswerOptionImage(checkboxGroup.context), null, diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DialogSelectViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DialogSelectViewHolderFactory.kt index 85769b3679..206a4b77e2 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DialogSelectViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DialogSelectViewHolderFactory.kt @@ -32,6 +32,7 @@ import com.google.android.fhir.datacapture.extensions.getRequiredOrOptionalText import com.google.android.fhir.datacapture.extensions.getValidationErrorMessage import com.google.android.fhir.datacapture.extensions.itemControl import com.google.android.fhir.datacapture.extensions.localizedFlyoverSpanned +import com.google.android.fhir.datacapture.extensions.toSpanned import com.google.android.fhir.datacapture.extensions.tryUnwrapContext import com.google.android.fhir.datacapture.validation.ValidationResult import com.google.android.fhir.datacapture.views.HeaderView @@ -76,12 +77,12 @@ internal object QuestionnaireItemDialogSelectViewHolderFactory : val questionnaireItem = questionnaireViewItem.questionnaireItem val selectedOptions = questionnaireViewItem.extractInitialOptions(holder.header.context) - holder.summary.text = selectedOptions.selectedSummary + holder.summary.text = selectedOptions.selectedSummary.toSpanned() selectedOptionsJob = activity.lifecycleScope.launch { // Listen for changes to selected options to update summary + FHIR data model viewModel.getSelectedOptionsFlow(questionnaireItem.linkId).collect { selectedOptions -> - holder.summary.text = selectedOptions.selectedSummary + holder.summary.text = selectedOptions.selectedSummary.toSpanned() updateAnswers(selectedOptions) } } diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DropDownViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DropDownViewHolderFactory.kt index 1a3c581f1c..8bcfc54e31 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DropDownViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DropDownViewHolderFactory.kt @@ -18,6 +18,7 @@ package com.google.android.fhir.datacapture.views.factories import android.content.Context import android.graphics.drawable.Drawable +import android.text.Spanned import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -33,6 +34,7 @@ import com.google.android.fhir.datacapture.extensions.getValidationErrorMessage import com.google.android.fhir.datacapture.extensions.identifierString import com.google.android.fhir.datacapture.extensions.itemAnswerOptionImage import com.google.android.fhir.datacapture.extensions.localizedFlyoverSpanned +import com.google.android.fhir.datacapture.extensions.toSpanned import com.google.android.fhir.datacapture.extensions.tryUnwrapContext import com.google.android.fhir.datacapture.validation.ValidationResult import com.google.android.fhir.datacapture.views.HeaderView @@ -92,8 +94,8 @@ internal object DropDownViewHolderFactory : answerOptionList .firstOrNull { it.answerId == selectedAnswerIdentifier } ?.let { - autoCompleteTextView.setText(it.answerOptionString) - autoCompleteTextView.setSelection(it.answerOptionString.length) + autoCompleteTextView.setText(it.answerOptionStringSpanned()) + autoCompleteTextView.setSelection(it.answerOptionStringSpanned().length) autoCompleteTextView.setCompoundDrawablesRelative( it.answerOptionImage, null, @@ -105,7 +107,7 @@ internal object DropDownViewHolderFactory : autoCompleteTextView.onItemClickListener = AdapterView.OnItemClickListener { _, _, position, _ -> val selectedItem = adapter.getItem(position) - autoCompleteTextView.setText(selectedItem?.answerOptionString, false) + autoCompleteTextView.setText(selectedItem?.answerOptionStringSpanned(), false) autoCompleteTextView.setCompoundDrawablesRelative( adapter.getItem(position)?.answerOptionImage, null, @@ -165,7 +167,7 @@ internal class AnswerOptionDropDownArrayAdapter( val answerOption: DropDownAnswerOption? = getItem(position) val answerOptionTextView = listItemView?.findViewById(R.id.answer_option_textview) as TextView - answerOptionTextView.text = answerOption?.answerOptionString + answerOptionTextView.text = answerOption?.answerOptionStringSpanned() answerOptionTextView.setCompoundDrawablesRelative( answerOption?.answerOptionImage, null, @@ -187,4 +189,6 @@ internal data class DropDownAnswerOption( override fun toString(): String { return this.answerOptionString } + + fun answerOptionStringSpanned(): Spanned = answerOptionString.toSpanned() } diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/RadioGroupViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/RadioGroupViewHolderFactory.kt index 876a4f3ac6..f912560425 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/RadioGroupViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/RadioGroupViewHolderFactory.kt @@ -28,7 +28,7 @@ import androidx.lifecycle.lifecycleScope import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.extensions.ChoiceOrientationTypes import com.google.android.fhir.datacapture.extensions.choiceOrientation -import com.google.android.fhir.datacapture.extensions.displayString +import com.google.android.fhir.datacapture.extensions.displayStringSpanned import com.google.android.fhir.datacapture.extensions.itemAnswerOptionImage import com.google.android.fhir.datacapture.extensions.tryUnwrapContext import com.google.android.fhir.datacapture.validation.Invalid @@ -113,7 +113,7 @@ internal object RadioGroupViewHolderFactory : val radioButton = radioButtonItem.findViewById(R.id.radio_button).apply { id = viewId - text = answerOption.value.displayString(header.context) + text = answerOption.value.displayStringSpanned(header.context) setCompoundDrawablesRelative( answerOption.itemAnswerOptionImage(radioGroup.context), null, diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/ReviewViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/ReviewViewHolderFactory.kt index a6621b2aba..cb69258760 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/ReviewViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/ReviewViewHolderFactory.kt @@ -26,6 +26,7 @@ import com.google.android.fhir.datacapture.extensions.getHeaderViewVisibility import com.google.android.fhir.datacapture.extensions.getLocalizedInstructionsSpanned import com.google.android.fhir.datacapture.extensions.localizedFlyoverSpanned import com.google.android.fhir.datacapture.extensions.localizedPrefixSpanned +import com.google.android.fhir.datacapture.extensions.toSpanned import com.google.android.fhir.datacapture.extensions.updateTextAndVisibility import com.google.android.fhir.datacapture.validation.Invalid import com.google.android.fhir.datacapture.views.QuestionnaireViewItem @@ -90,7 +91,7 @@ internal object ReviewViewHolderFactory : QuestionnaireItemViewHolderFactory(R.l answerView.visibility = GONE } else -> { - answerView.text = questionnaireViewItem.answerString(answerView.context) + answerView.text = questionnaireViewItem.answerString(answerView.context).toSpanned() answerView.visibility = VISIBLE if (questionnaireViewItem.validationResult is Invalid) { errorView.findViewById(R.id.error_text_view).text = 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/QuestionnaireEditAdapterTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireEditAdapterTest.kt index 6dd1a3e33d..7f90968396 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireEditAdapterTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireEditAdapterTest.kt @@ -19,6 +19,7 @@ package com.google.android.fhir.datacapture import android.os.Build import android.widget.FrameLayout import androidx.test.core.app.ApplicationProvider +import com.google.android.fhir.datacapture.extensions.EXTENSION_DIALOG_URL_ANDROID_FHIR import com.google.android.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_SYSTEM import com.google.android.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_SYSTEM_ANDROID_FHIR import com.google.android.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_URL @@ -496,6 +497,82 @@ class QuestionnaireEditAdapterTest { .isEqualTo(QuestionnaireViewHolderType.DROP_DOWN.value) } + @Test + fun `getItemViewType() with radio button and dialog extension should return dialog select view holder type`() { + val questionnaireEditAdapter = QuestionnaireEditAdapter() + val questionnaireItem = + Questionnaire.QuestionnaireItemComponent().setType(Questionnaire.QuestionnaireItemType.CHOICE) + questionnaireItem.apply { + addExtension( + Extension() + .setUrl(EXTENSION_ITEM_CONTROL_URL) + .setValue( + CodeableConcept() + .addCoding( + Coding() + .setCode(ItemControlTypes.RADIO_BUTTON.extensionCode) + .setDisplay("Radio Button") + .setSystem(EXTENSION_ITEM_CONTROL_SYSTEM), + ), + ), + ) + addExtension(Extension().setUrl(EXTENSION_DIALOG_URL_ANDROID_FHIR)) + } + questionnaireEditAdapter.submitList( + listOf( + QuestionnaireAdapterItem.Question( + QuestionnaireViewItem( + questionnaireItem, + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ), + ), + ) + + assertThat(questionnaireEditAdapter.getItemViewType(0)) + .isEqualTo(QuestionnaireViewHolderType.DIALOG_SELECT.value) + } + + @Test + fun `getItemViewType() with check box and dialog extension should return dialog select view holder type`() { + val questionnaireEditAdapter = QuestionnaireEditAdapter() + val questionnaireItem = + Questionnaire.QuestionnaireItemComponent().setType(Questionnaire.QuestionnaireItemType.CHOICE) + questionnaireItem.apply { + addExtension( + Extension() + .setUrl(EXTENSION_ITEM_CONTROL_URL) + .setValue( + CodeableConcept() + .addCoding( + Coding() + .setCode(ItemControlTypes.CHECK_BOX.extensionCode) + .setDisplay("Check Box") + .setSystem(EXTENSION_ITEM_CONTROL_SYSTEM), + ), + ), + ) + addExtension(Extension().setUrl(EXTENSION_DIALOG_URL_ANDROID_FHIR)) + } + questionnaireEditAdapter.submitList( + listOf( + QuestionnaireAdapterItem.Question( + QuestionnaireViewItem( + questionnaireItem, + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ), + ), + ) + + assertThat(questionnaireEditAdapter.getItemViewType(0)) + .isEqualTo(QuestionnaireViewHolderType.DIALOG_SELECT.value) + } + // TODO: test errors thrown for unsupported types @Test 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/QuestionnaireViewModelParameterizedTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelParameterizedTest.kt index f6c62956a3..312ff4fd2a 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelParameterizedTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelParameterizedTest.kt @@ -29,6 +29,7 @@ import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_QUESTIONNAIRE_RESPONSE_JSON_URI import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_SHOW_REVIEW_PAGE_FIRST +import com.google.android.fhir.datacapture.extensions.EXTENSION_LAST_LAUNCHED_TIMESTAMP import com.google.android.fhir.datacapture.testing.DataCaptureTestApplication import com.google.common.truth.Truth.assertThat import java.io.File @@ -89,7 +90,7 @@ class QuestionnaireViewModelParameterizedTest( val viewModel = createQuestionnaireViewModel(questionnaire) runTest { - assertResourceEquals( + assertQuestionnaireResponseEqualsIgnoringTimestamps( viewModel.getQuestionnaireResponse(), QuestionnaireResponse().apply { this.questionnaire = "http://www.sample-org/FHIR/Resources/Questionnaire/a-questionnaire" @@ -135,7 +136,12 @@ class QuestionnaireViewModelParameterizedTest( val viewModel = createQuestionnaireViewModel(questionnaire, questionnaireResponse) - runTest { assertResourceEquals(viewModel.getQuestionnaireResponse(), questionnaireResponse) } + runTest { + assertQuestionnaireResponseEqualsIgnoringTimestamps( + viewModel.getQuestionnaireResponse(), + questionnaireResponse, + ) + } } private fun createQuestionnaireViewModel( @@ -187,6 +193,28 @@ class QuestionnaireViewModelParameterizedTest( .isEqualTo(printer.encodeResourceToString(expected)) } + /** + * Asserts that the `expected` and the `actual` Questionnaire Responses are equal ignoring the + * stamp values + */ + fun assertQuestionnaireResponseEqualsIgnoringTimestamps( + actual: QuestionnaireResponse, + expected: QuestionnaireResponse, + ) { + val actualResponseWithoutTimestamp = + actual.copy().apply { + extension.removeIf { ext -> ext.url == EXTENSION_LAST_LAUNCHED_TIMESTAMP } + authored = null + } + val expectedResponseWithoutTimestamp = + expected.copy().apply { + extension.removeIf { ext -> ext.url == EXTENSION_LAST_LAUNCHED_TIMESTAMP } + authored = null + } + assertThat(printer.encodeResourceToString(actualResponseWithoutTimestamp)) + .isEqualTo(printer.encodeResourceToString(expectedResponseWithoutTimestamp)) + } + @JvmStatic @Parameters fun parameters() = 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 e644e4c6d6..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 @@ -48,6 +48,7 @@ import com.google.android.fhir.datacapture.extensions.EXTENSION_ENTRY_MODE_URL import com.google.android.fhir.datacapture.extensions.EXTENSION_HIDDEN_URL import com.google.android.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_SYSTEM import com.google.android.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_URL +import com.google.android.fhir.datacapture.extensions.EXTENSION_LAST_LAUNCHED_TIMESTAMP import com.google.android.fhir.datacapture.extensions.EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT import com.google.android.fhir.datacapture.extensions.EXTENSION_VARIABLE_URL import com.google.android.fhir.datacapture.extensions.EntryMode @@ -187,7 +188,7 @@ class QuestionnaireViewModelTest { val viewModel = createQuestionnaireViewModel(questionnaire) runTest { - assertResourceEquals( + assertQuestionnaireResponseEqualsIgnoringTimestamps( viewModel.getQuestionnaireResponse(), QuestionnaireResponse().apply { this.questionnaire = "http://www.sample-org/FHIR/Resources/Questionnaire/a-questionnaire" @@ -213,7 +214,7 @@ class QuestionnaireViewModelTest { val viewModel = createQuestionnaireViewModel(questionnaire) runTest { - assertResourceEquals( + assertQuestionnaireResponseEqualsIgnoringTimestamps( viewModel.getQuestionnaireResponse(), QuestionnaireResponse().apply { addItem( @@ -251,7 +252,7 @@ class QuestionnaireViewModelTest { val viewModel = createQuestionnaireViewModel(questionnaire) runTest { - assertResourceEquals( + assertQuestionnaireResponseEqualsIgnoringTimestamps( viewModel.getQuestionnaireResponse(), QuestionnaireResponse().apply { addItem( @@ -324,7 +325,7 @@ class QuestionnaireViewModelTest { val viewModel = createQuestionnaireViewModel(questionnaire) runTest { - assertResourceEquals( + assertQuestionnaireResponseEqualsIgnoringTimestamps( viewModel.getQuestionnaireResponse(), QuestionnaireResponse().apply { addItem( @@ -422,7 +423,7 @@ class QuestionnaireViewModelTest { val viewModel = createQuestionnaireViewModel(questionnaire) runTest { - assertResourceEquals( + assertQuestionnaireResponseEqualsIgnoringTimestamps( viewModel.getQuestionnaireResponse(), QuestionnaireResponse().apply { addItem( @@ -497,7 +498,12 @@ class QuestionnaireViewModelTest { val viewModel = createQuestionnaireViewModel(questionnaire, questionnaireResponse) - runTest { assertResourceEquals(viewModel.getQuestionnaireResponse(), questionnaireResponse) } + runTest { + assertQuestionnaireResponseEqualsIgnoringTimestamps( + viewModel.getQuestionnaireResponse(), + questionnaireResponse, + ) + } } @Test @@ -565,7 +571,7 @@ class QuestionnaireViewModelTest { } runTest { - assertResourceEquals( + assertQuestionnaireResponseEqualsIgnoringTimestamps( createQuestionnaireViewModel( questionnaire, questionnaireResponse, @@ -651,7 +657,12 @@ class QuestionnaireViewModelTest { val viewModel = createQuestionnaireViewModel(questionnaire, questionnaireResponse) - runTest { assertResourceEquals(questionnaireResponse, viewModel.getQuestionnaireResponse()) } + runTest { + assertQuestionnaireResponseEqualsIgnoringTimestamps( + viewModel.getQuestionnaireResponse(), + questionnaireResponse, + ) + } } @Test @@ -742,7 +753,12 @@ class QuestionnaireViewModelTest { val viewModel = createQuestionnaireViewModel(questionnaire, questionnaireResponse) - runTest { assertResourceEquals(viewModel.getQuestionnaireResponse(), questionnaireResponse) } + runTest { + assertQuestionnaireResponseEqualsIgnoringTimestamps( + viewModel.getQuestionnaireResponse(), + questionnaireResponse, + ) + } } @Test @@ -794,7 +810,12 @@ class QuestionnaireViewModelTest { val viewModel = createQuestionnaireViewModel(questionnaire, questionnaireResponse) - runTest { assertResourceEquals(viewModel.getQuestionnaireResponse(), questionnaireResponse) } + runTest { + assertQuestionnaireResponseEqualsIgnoringTimestamps( + viewModel.getQuestionnaireResponse(), + questionnaireResponse, + ) + } } @Test @@ -853,7 +874,12 @@ class QuestionnaireViewModelTest { val viewModel = createQuestionnaireViewModel(questionnaire, questionnaireResponse) - runTest { assertResourceEquals(viewModel.getQuestionnaireResponse(), questionnaireResponse) } + runTest { + assertQuestionnaireResponseEqualsIgnoringTimestamps( + viewModel.getQuestionnaireResponse(), + questionnaireResponse, + ) + } } @Test @@ -895,7 +921,12 @@ class QuestionnaireViewModelTest { val viewModel = createQuestionnaireViewModel(questionnaire, questionnaireResponse) - runTest { assertResourceEquals(viewModel.getQuestionnaireResponse(), questionnaireResponse) } + runTest { + assertQuestionnaireResponseEqualsIgnoringTimestamps( + viewModel.getQuestionnaireResponse(), + questionnaireResponse, + ) + } } @Test @@ -1006,7 +1037,7 @@ class QuestionnaireViewModelTest { printer.parseResource(QuestionnaireResponse::class.java, expectedResponseString) as QuestionnaireResponse - assertResourceEquals(value, expectedResponse) + assertQuestionnaireResponseEqualsIgnoringTimestamps(value, expectedResponse) } } @@ -1134,7 +1165,7 @@ class QuestionnaireViewModelTest { printer.parseResource(QuestionnaireResponse::class.java, expectedResponseString) as QuestionnaireResponse - assertResourceEquals(value, expectedResponse) + assertQuestionnaireResponseEqualsIgnoringTimestamps(value, expectedResponse) } } @@ -1502,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 { @@ -1799,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( @@ -1814,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() } } @@ -3340,7 +3547,7 @@ class QuestionnaireViewModelTest { val viewModel = createQuestionnaireViewModel(questionnaire) runTest { - assertResourceEquals( + assertQuestionnaireResponseEqualsIgnoringTimestamps( viewModel.getQuestionnaireResponse(), QuestionnaireResponse().apply { addItem( @@ -3387,7 +3594,7 @@ class QuestionnaireViewModelTest { val viewModel = createQuestionnaireViewModel(questionnaire) runTest { - assertResourceEquals( + assertQuestionnaireResponseEqualsIgnoringTimestamps( viewModel.getQuestionnaireResponse(), QuestionnaireResponse().apply { addItem( @@ -3435,7 +3642,7 @@ class QuestionnaireViewModelTest { val viewModel = createQuestionnaireViewModel(questionnaire) runTest { - assertResourceEquals( + assertQuestionnaireResponseEqualsIgnoringTimestamps( viewModel.getQuestionnaireResponse(), QuestionnaireResponse().apply { addItem( @@ -3489,7 +3696,7 @@ class QuestionnaireViewModelTest { val viewModel = QuestionnaireViewModel(context, state) - assertResourceEquals( + assertQuestionnaireResponseEqualsIgnoringTimestamps( viewModel.getQuestionnaireResponse(), QuestionnaireResponse().apply { addItem( @@ -3534,7 +3741,7 @@ class QuestionnaireViewModelTest { val viewModel = QuestionnaireViewModel(context, state) - assertResourceEquals( + assertQuestionnaireResponseEqualsIgnoringTimestamps( viewModel.getQuestionnaireResponse(), QuestionnaireResponse().apply { addItem( @@ -3621,7 +3828,10 @@ class QuestionnaireViewModelTest { val viewModel = QuestionnaireViewModel(context, state) - assertResourceEquals(viewModel.getQuestionnaireResponse(), questionnaireResponse) + assertQuestionnaireResponseEqualsIgnoringTimestamps( + viewModel.getQuestionnaireResponse(), + questionnaireResponse, + ) } @Test @@ -3787,7 +3997,7 @@ class QuestionnaireViewModelTest { // Clearing the answer disables question-2 that in turn disables question-3. items.first { it.questionnaireItem.linkId == "question-1" }.clearAnswer() - assertResourceEquals( + assertQuestionnaireResponseEqualsIgnoringTimestamps( viewModel.getQuestionnaireResponse(), QuestionnaireResponse().apply { id = "a-questionnaire-response" @@ -3809,7 +4019,10 @@ class QuestionnaireViewModelTest { }, ) - assertResourceEquals(viewModel.getQuestionnaireResponse(), questionnaireResponse) + assertQuestionnaireResponseEqualsIgnoringTimestamps( + viewModel.getQuestionnaireResponse(), + questionnaireResponse, + ) } } @@ -4180,7 +4393,7 @@ class QuestionnaireViewModelTest { printer.parseResource(QuestionnaireResponse::class.java, expectedResponseString) as QuestionnaireResponse - assertResourceEquals(value, expectedResponse) + assertQuestionnaireResponseEqualsIgnoringTimestamps(value, expectedResponse) } } @@ -4278,7 +4491,7 @@ class QuestionnaireViewModelTest { val expectedResponse = printer.parseResource(QuestionnaireResponse::class.java, expectedResponseString) as QuestionnaireResponse - assertResourceEquals(value, expectedResponse) + assertQuestionnaireResponseEqualsIgnoringTimestamps(value, expectedResponse) } } @@ -4339,7 +4552,10 @@ class QuestionnaireViewModelTest { }, ) - assertResourceEquals(viewModel.getQuestionnaireResponse(), questionnaireResponse) + assertQuestionnaireResponseEqualsIgnoringTimestamps( + viewModel.getQuestionnaireResponse(), + questionnaireResponse, + ) } } @@ -4459,7 +4675,7 @@ class QuestionnaireViewModelTest { ) .inOrder() - assertResourceEquals( + assertQuestionnaireResponseEqualsIgnoringTimestamps( actual = viewModel.getQuestionnaireResponse(), expected = QuestionnaireResponse().apply { @@ -4641,7 +4857,10 @@ class QuestionnaireViewModelTest { }, ) - assertResourceEquals(viewModel.getQuestionnaireResponse(), questionnaireResponse) + assertQuestionnaireResponseEqualsIgnoringTimestamps( + viewModel.getQuestionnaireResponse(), + questionnaireResponse, + ) } } @@ -7517,6 +7736,28 @@ class QuestionnaireViewModelTest { assertThat(printer.encodeResourceToString(actual)) .isEqualTo(printer.encodeResourceToString(expected)) } + + /** + * Asserts that the `expected` and the `actual` Questionnaire Responses are equal ignoring the + * stamp values + */ + private fun assertQuestionnaireResponseEqualsIgnoringTimestamps( + actual: QuestionnaireResponse, + expected: QuestionnaireResponse, + ) { + val actualResponseWithoutTimestamp = + actual.copy().apply { + extension.removeIf { ext -> ext.url == EXTENSION_LAST_LAUNCHED_TIMESTAMP } + authored = null + } + val expectedResponseWithoutTimestamp = + expected.copy().apply { + extension.removeIf { ext -> ext.url == EXTENSION_LAST_LAUNCHED_TIMESTAMP } + authored = null + } + assertThat(printer.encodeResourceToString(actualResponseWithoutTimestamp)) + .isEqualTo(printer.encodeResourceToString(expectedResponseWithoutTimestamp)) + } } } diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireResponsesTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireResponsesTest.kt index 165d95fe45..275d031f01 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireResponsesTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireResponsesTest.kt @@ -20,6 +20,7 @@ import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.context.FhirVersionEnum import com.google.common.truth.Truth.assertThat import org.hl7.fhir.r4.model.BooleanType +import org.hl7.fhir.r4.model.DateTimeType import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent import org.hl7.fhir.r4.model.QuestionnaireResponse @@ -260,6 +261,121 @@ class MoreQuestionnaireResponsesTest { assertResourceEquals(questionnaireResponse, packedQuestionnaireResponse) } + @Test + fun `should pack repeated groups recursively`() { + val questionnaire = + Questionnaire().apply { + addItem( + QuestionnaireItemComponent().apply { + linkId = "repeated-group" + type = Questionnaire.QuestionnaireItemType.GROUP + repeats = true + addItem( + QuestionnaireItemComponent().apply { + linkId = "nested-repeated-group" + type = Questionnaire.QuestionnaireItemType.GROUP + repeats = true + addItem( + QuestionnaireItemComponent().apply { + linkId = "nested-nested-question" + type = Questionnaire.QuestionnaireItemType.BOOLEAN + }, + ) + }, + ) + }, + ) + } + + val questionnaireResponse = + QuestionnaireResponse().apply { + addItem( + QuestionnaireResponseItemComponent().apply { + linkId = "repeated-group" + addItem( + QuestionnaireResponseItemComponent().apply { + linkId = "nested-repeated-group" + addItem( + QuestionnaireResponseItemComponent().apply { + linkId = "nested-nested-question" + addAnswer( + QuestionnaireResponseItemAnswerComponent().apply { + value = BooleanType(true) + }, + ) + }, + ) + }, + ) + addItem( + QuestionnaireResponseItemComponent().apply { + linkId = "nested-repeated-group" + addItem( + QuestionnaireResponseItemComponent().apply { + linkId = "nested-nested-question" + addAnswer( + QuestionnaireResponseItemAnswerComponent().apply { + value = BooleanType(false) + }, + ) + }, + ) + }, + ) + }, + ) + } + + val packedQuestionnaireResponse = + QuestionnaireResponse().apply { + addItem( + QuestionnaireResponseItemComponent().apply { + linkId = "repeated-group" + addAnswer( + QuestionnaireResponseItemAnswerComponent().apply { + addItem( + QuestionnaireResponseItemComponent().apply { + linkId = "nested-repeated-group" + addAnswer( + QuestionnaireResponseItemAnswerComponent().apply { + addItem( + QuestionnaireResponseItemComponent().apply { + linkId = "nested-nested-question" + addAnswer( + QuestionnaireResponseItemAnswerComponent().apply { + value = BooleanType(true) + }, + ) + }, + ) + }, + ) + addAnswer( + QuestionnaireResponseItemAnswerComponent().apply { + addItem( + QuestionnaireResponseItemComponent().apply { + linkId = "nested-nested-question" + addAnswer( + QuestionnaireResponseItemAnswerComponent().apply { + value = BooleanType(false) + }, + ) + }, + ) + }, + ) + }, + ) + }, + ) + }, + ) + } + + questionnaireResponse.packRepeatedGroups(questionnaire) + assertResourceEquals(questionnaireResponse, packedQuestionnaireResponse) + } + @Test fun `should not modify non-repeated groups while packing repeated groups`() { val questionnaire = @@ -695,4 +811,29 @@ class MoreQuestionnaireResponsesTest { assertThat(iParser.encodeResourceToString(actual)) .isEqualTo(iParser.encodeResourceToString(expected)) } + + @Test + fun `should add launchTimestamp`() { + val questionnaireResponse = QuestionnaireResponse() + val dateTimeType = DateTimeType("2024-07-05T00:00:00Z") + questionnaireResponse.launchTimestamp = dateTimeType + + assertThat(dateTimeType).isEqualTo(questionnaireResponse.launchTimestamp) + } + + @Test + fun `launchTimestamp should be null when not added`() { + assertThat(QuestionnaireResponse().launchTimestamp).isNull() + } + + @Test + fun `launchTimestamp should update if already exists`() { + val questionnaireResponse = QuestionnaireResponse() + val oldDateTimeType = DateTimeType("2024-07-01T00:00:00Z") + val newDateTimeType = DateTimeType("2024-07-05T00:00:00Z") + questionnaireResponse.launchTimestamp = oldDateTimeType + questionnaireResponse.launchTimestamp = newDateTimeType + + assertThat(newDateTimeType).isEqualTo(questionnaireResponse.launchTimestamp) + } } 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/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/CheckBoxGroupViewHolderFactoryTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/CheckBoxGroupViewHolderFactoryTest.kt index bc49267601..f5f361cc87 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/CheckBoxGroupViewHolderFactoryTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/CheckBoxGroupViewHolderFactoryTest.kt @@ -124,7 +124,8 @@ class CheckBoxGroupViewHolderFactoryTest { val checkBoxGroup = viewHolder.itemView.findViewById(R.id.checkbox_group) val children = checkBoxGroup.children.asIterable().filterIsInstance() children.forEachIndexed { index, view -> - assertThat(view.text).isEqualTo(questionnaire.answerOption[index].valueCoding.display) + assertThat(view.text.toString()) + .isEqualTo(questionnaire.answerOption[index].valueCoding.display) assertThat(view.layoutParams.width).isEqualTo(ViewGroup.LayoutParams.MATCH_PARENT) } } @@ -161,7 +162,8 @@ class CheckBoxGroupViewHolderFactoryTest { val checkBoxGroup = viewHolder.itemView.findViewById(R.id.checkbox_group) val children = checkBoxGroup.children.asIterable().filterIsInstance() children.forEachIndexed { index, view -> - assertThat(view.text).isEqualTo(questionnaire.answerOption[index].valueCoding.display) + assertThat(view.text.toString()) + .isEqualTo(questionnaire.answerOption[index].valueCoding.display) assertThat(view.layoutParams.width).isEqualTo(ViewGroup.LayoutParams.WRAP_CONTENT) } } diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/RadioGroupViewHolderFactoryTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/RadioGroupViewHolderFactoryTest.kt index cb2d536d58..6f4bc8a3e4 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/RadioGroupViewHolderFactoryTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/RadioGroupViewHolderFactoryTest.kt @@ -118,7 +118,8 @@ class RadioGroupViewHolderFactoryTest { val radioGroup = viewHolder.itemView.findViewById(R.id.radio_group) val children = radioGroup.children.asIterable().filterIsInstance() children.forEachIndexed { index, view -> - assertThat(view.text).isEqualTo(questionnaire.answerOption[index].valueCoding.display) + assertThat(view.text.toString()) + .isEqualTo(questionnaire.answerOption[index].valueCoding.display) assertThat(view.layoutParams.width).isEqualTo(ViewGroup.LayoutParams.MATCH_PARENT) } } @@ -154,7 +155,8 @@ class RadioGroupViewHolderFactoryTest { val radioGroup = viewHolder.itemView.findViewById(R.id.radio_group) val children = radioGroup.children.asIterable().filterIsInstance() children.forEachIndexed { index, view -> - assertThat(view.text).isEqualTo(questionnaire.answerOption[index].valueCoding.display) + assertThat(view.text.toString()) + .isEqualTo(questionnaire.answerOption[index].valueCoding.display) assertThat(view.layoutParams.width).isEqualTo(ViewGroup.LayoutParams.WRAP_CONTENT) } } diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/ReviewViewHolderFactoryTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/ReviewViewHolderFactoryTest.kt index cfa8ea2408..3e43a24d3d 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/ReviewViewHolderFactoryTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/ReviewViewHolderFactoryTest.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. @@ -180,7 +180,7 @@ class ReviewViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.answer_text_view).text) + assertThat(viewHolder.itemView.findViewById(R.id.answer_text_view).text.toString()) .isEqualTo( ApplicationProvider.getApplicationContext().getString(R.string.not_answered), ) @@ -206,7 +206,7 @@ class ReviewViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.answer_text_view).text) + assertThat(viewHolder.itemView.findViewById(R.id.answer_text_view).text.toString()) .isEqualTo(ApplicationProvider.getApplicationContext().getString(R.string.yes)) } @@ -321,7 +321,7 @@ class ReviewViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.answer_text_view).text) + assertThat(viewHolder.itemView.findViewById(R.id.answer_text_view).text.toString()) .isEqualTo("Yes") } @@ -340,7 +340,7 @@ class ReviewViewHolderFactoryTest { ), ) - assertThat(viewHolder.itemView.findViewById(R.id.answer_text_view).text) + assertThat(viewHolder.itemView.findViewById(R.id.answer_text_view).text.toString()) .isEqualTo("Not Answered") } diff --git a/demo/build.gradle.kts b/demo/build.gradle.kts index 7d0fb5c13c..18c4cd7f1f 100644 --- a/demo/build.gradle.kts +++ b/demo/build.gradle.kts @@ -43,7 +43,6 @@ dependencies { coreLibraryDesugaring(Dependencies.desugarJdkLibs) - implementation(Dependencies.material) implementation(Dependencies.timber) implementation(libs.androidx.activity) implementation(libs.androidx.appcompat) @@ -60,6 +59,7 @@ dependencies { implementation(libs.kotlin.stdlib) implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.core) + implementation(libs.material) implementation(project(":datacapture")) { exclude(group = "com.google.android.fhir", module = "engine") } 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