diff --git a/catalog/src/main/assets/component_auto_complete.json b/catalog/src/main/assets/component_auto_complete.json index f46d1e7b3e..1955129d11 100644 --- a/catalog/src/main/assets/component_auto_complete.json +++ b/catalog/src/main/assets/component_auto_complete.json @@ -93,6 +93,27 @@ } } ] + }, + { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://hl7.org/fhir/questionnaire-item-control", + "code": "autocomplete" + } + ] + } + } + ], + "linkId": "2", + "text": "Procedure", + "type": "choice", + "required": true, + "repeats": false, + "answerValueSet": "https://my.url/fhir/ValueSet/my-valueset" } ] } diff --git a/catalog/src/main/java/com/google/android/fhir/catalog/CatalogApplication.kt b/catalog/src/main/java/com/google/android/fhir/catalog/CatalogApplication.kt index 204651fd02..d52e5a591a 100644 --- a/catalog/src/main/java/com/google/android/fhir/catalog/CatalogApplication.kt +++ b/catalog/src/main/java/com/google/android/fhir/catalog/CatalogApplication.kt @@ -22,11 +22,14 @@ import com.google.android.fhir.FhirEngine import com.google.android.fhir.FhirEngineConfiguration import com.google.android.fhir.FhirEngineProvider import com.google.android.fhir.datacapture.DataCaptureConfig +import com.google.android.fhir.datacapture.ExternalAnswerValueSetResolver import com.google.android.fhir.search.search import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.Coding class CatalogApplication : Application(), DataCaptureConfig.Provider { // Only initiate the FhirEngine when used for the first time, not when the app is created. @@ -44,6 +47,26 @@ class CatalogApplication : Application(), DataCaptureConfig.Provider { xFhirQueryResolver = { fhirEngine.search(it).map { it.resource } }, questionnaireItemViewHolderFactoryMatchersProviderFactory = ContribQuestionnaireItemViewHolderFactoryMatchersProviderFactory, + valueSetResolverExternal = + object : ExternalAnswerValueSetResolver { + override suspend fun resolve(uri: String, query: String?): List { + delay(1000) + // Here we can call out to our FHIR terminology server with the provided uri and query + if (uri == "https://my.url/fhir/ValueSet/my-valueset" && !query.isNullOrBlank()) { + return listOf( + Coding().apply { + code = "a" + display = "Custom response A" + }, + Coding().apply { + code = "b" + display = "Custom response B" + }, + ) + } + return emptyList() + } + }, ) CoroutineScope(Dispatchers.IO).launch { diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/DataCaptureConfig.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/DataCaptureConfig.kt index 34208ad29b..55bd788be7 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/DataCaptureConfig.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/DataCaptureConfig.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 Google LLC + * Copyright 2022-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. @@ -74,12 +74,18 @@ data class DataCaptureConfig( * allows the library to render answer options to `choice` and `open-choice` type questions more * dynamically. * + * Optional query parameter can be used to accept the search string from user input for server-side + * filtering. + * * NOTE: The result of the resolution may be cached to improve performance. In other words, the * resolver may be called only once after which the same answer value set may be used multiple times * in the UI to populate answer options. + * + * @param uri The uri used to identify the questionnaire item + * @param query The text input from the user */ interface ExternalAnswerValueSetResolver { - suspend fun resolve(uri: String): List + suspend fun resolve(uri: String, query: String?): List } /** 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 6fc427eae2..ef6e879ea6 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 @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 Google LLC + * Copyright 2022-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. 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 ecc019b0a8..f3294609c0 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 @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 Google LLC + * Copyright 2023-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. @@ -76,6 +76,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.Coding import org.hl7.fhir.r4.model.DateTimeType import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent @@ -389,6 +390,23 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat modificationCount.update { it + 1 } } + /** + * Function to dynamically resolve answer options for the AutoComplete component using + * [ExternalAnswerValueSetResolver.resolve] + */ + private val autoCompleteAnswerOptionResolver: (String, String?, (List) -> Unit) -> Unit = + { query, answerValueSet, callback -> + viewModelScope.launch { + val response = + if (externalValueSetResolver != null && answerValueSet != null) { + externalValueSetResolver!!.resolve(query, answerValueSet) + } else { + emptyList() + } + callback(response) + } + } + private val expressionEvaluator: ExpressionEvaluator = ExpressionEvaluator( questionnaire, @@ -950,6 +968,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat validationResult = validationResult, answersChangedCallback = answersChangedCallback, enabledAnswerOptions = enabledQuestionnaireAnswerOptions, + autoCompleteAnswerOptionResolver = autoCompleteAnswerOptionResolver, minAnswerValue = questionnaireItem.minValueCqfCalculatedValueExpression?.let { expressionEvaluator.evaluateExpressionValue( @@ -985,6 +1004,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat ), isHelpCardOpen = isHelpCard && isHelpCardOpen, helpCardStateChangedCallback = helpCardStateChangedCallback, + // suggestions = suggestions, ), ) add(question) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/expressions/EnabledAnswerOptionsEvaluator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/expressions/EnabledAnswerOptionsEvaluator.kt index 94c633221e..2c1fec912f 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/expressions/EnabledAnswerOptionsEvaluator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/expressions/EnabledAnswerOptionsEvaluator.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 Google LLC + * Copyright 2022-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. @@ -179,7 +179,7 @@ internal class EnabledAnswerOptionsEvaluator( } } else { // Ask the client to provide the answers from an external expanded Valueset. - externalValueSetResolver?.resolve(uri)?.map { coding -> + externalValueSetResolver?.resolve(uri, null)?.map { coding -> Questionnaire.QuestionnaireItemAnswerOptionComponent(coding.copy()) } } diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt index d9025fd284..fe5ed498b6 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 Google LLC + * Copyright 2023-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. @@ -30,6 +30,7 @@ import com.google.android.fhir.datacapture.validation.NotValidated import com.google.android.fhir.datacapture.validation.Valid import com.google.android.fhir.datacapture.validation.ValidationResult import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder +import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent import org.hl7.fhir.r4.model.QuestionnaireResponse @@ -93,6 +94,9 @@ data class QuestionnaireViewItem( val helpCardStateChangedCallback: (Boolean, QuestionnaireResponseItemComponent) -> Unit = { _, _ -> }, + internal val autoCompleteAnswerOptionResolver: (String, String?, (List) -> Unit) -> Unit = + { _, _, _ -> + }, ) { fun getQuestionnaireResponseItem(): QuestionnaireResponseItemComponent = questionnaireResponseItem diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/AutoCompleteViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/AutoCompleteViewHolderFactory.kt index 149185aeb3..8e49a7c584 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/AutoCompleteViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/AutoCompleteViewHolderFactory.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 Google LLC + * Copyright 2022-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. @@ -16,14 +16,19 @@ package com.google.android.fhir.datacapture.views.factories +import android.content.Context +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.widget.AdapterView import android.widget.ArrayAdapter +import android.widget.Filter import android.widget.TextView import androidx.appcompat.app.AppCompatActivity import androidx.core.view.children import androidx.core.view.get import androidx.core.view.isEmpty +import androidx.core.widget.addTextChangedListener import androidx.lifecycle.lifecycleScope import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.extensions.displayString @@ -53,6 +58,8 @@ internal object AutoCompleteViewHolderFactory : private lateinit var autoCompleteTextView: MaterialAutoCompleteTextView private lateinit var chipContainer: ChipGroup private lateinit var textInputLayout: TextInputLayout + private lateinit var adapter: AutoCompleteArrayAdapter + private val canHaveMultipleAnswers get() = questionnaireViewItem.questionnaireItem.repeats @@ -66,24 +73,7 @@ internal object AutoCompleteViewHolderFactory : chipContainer = itemView.findViewById(R.id.chipContainer) textInputLayout = itemView.findViewById(R.id.text_input_layout) errorTextView = itemView.findViewById(R.id.error) - autoCompleteTextView.onItemClickListener = - AdapterView.OnItemClickListener { _, _, position, _ -> - val answer = - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { - value = - questionnaireViewItem.enabledAnswerOptions - .first { - it.value.identifierString(header.context) == - (autoCompleteTextView.adapter.getItem(position) - as AutoCompleteViewAnswerOption) - .answerId - } - .valueCoding - } - - onAnswerSelected(answer) - autoCompleteTextView.setText("") - } + autoCompleteTextView.onItemClickListener = onClickListener() } override fun bind(questionnaireViewItem: QuestionnaireViewItem) { @@ -96,12 +86,12 @@ internal object AutoCompleteViewHolderFactory : answerDisplay = it.value.displayString(header.context), ) } - val adapter = - ArrayAdapter( - header.context, - R.layout.drop_down_list_item, - R.id.answer_option_textview, - answerOptionValues, + adapter = + AutoCompleteArrayAdapter( + context = header.context, + resource = R.layout.drop_down_list_item, + textViewResourceId = R.id.answer_option_textview, + objects = answerOptionValues, ) autoCompleteTextView.setAdapter(adapter) // Remove chips if any from the last bindView call on this VH. @@ -109,6 +99,30 @@ internal object AutoCompleteViewHolderFactory : presetValuesIfAny() displayValidationResult(questionnaireViewItem.validationResult) + + val serverSideFiltering = + questionnaireViewItem.questionnaireItem.answerValueSet != null && + answerOptionValues.isEmpty() + + autoCompleteTextView.addTextChangedListener { text -> + if (serverSideFiltering) { + questionnaireViewItem.autoCompleteAnswerOptionResolver( + text.toString(), + questionnaireViewItem.questionnaireItem.answerValueSet, + ) { response -> + val items = + response.map { + AutoCompleteViewAnswerOption( + answerId = it.code, + answerDisplay = it.display, + ) + } + adapter.updateData(items) + } + } else { + adapter.clientSideFilter(text.toString()) + } + } } override fun setReadOnly(isReadOnly: Boolean) { @@ -136,6 +150,39 @@ internal object AutoCompleteViewHolderFactory : } } + private fun onClickListener() = + AdapterView.OnItemClickListener { _, _, position, _ -> + val answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent + if (questionnaireViewItem.enabledAnswerOptions.isEmpty()) { + val answerValue = + autoCompleteTextView.adapter.getItem(position) as AutoCompleteViewAnswerOption + val answerCoding = + Coding().apply { + code = answerValue.answerId + display = answerValue.answerDisplay + } + answer = + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = answerCoding + } + } else { + answer = + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = + questionnaireViewItem.enabledAnswerOptions + .first { + it.value.identifierString(header.context) == + (autoCompleteTextView.adapter.getItem(position) + as AutoCompleteViewAnswerOption) + .answerId + } + .valueCoding + } + } + onAnswerSelected(answer) + autoCompleteTextView.setText("") + } + /** * Adds a new chip if it not already present in [chipContainer].It returns [true] if a new * Chip is added and [false] if the Chip is already present for the selected answer. The later @@ -247,3 +294,58 @@ internal data class AutoCompleteViewAnswerOption(val answerId: String, val answe return this.answerDisplay } } + +internal class AutoCompleteArrayAdapter( + context: Context, + val resource: Int, + val textViewResourceId: Int, + private val objects: List, +) : ArrayAdapter(context, resource, textViewResourceId, objects) { + + private var items = listOf() + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = convertView ?: LayoutInflater.from(context).inflate(resource, parent, false) + val item = getItem(position) + view.findViewById(textViewResourceId).text = item.toString() + return view + } + + override fun getCount(): Int = items.size + + fun updateData(newData: List) { + items = newData + notifyDataSetChanged() + } + + fun clientSideFilter(query: String) { + items = objects.filter { it.answerDisplay.contains(query, ignoreCase = true) } + notifyDataSetChanged() + } + + override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { + return getView(position, convertView, parent) + } + + override fun getItem(position: Int): AutoCompleteViewAnswerOption? = items.getOrNull(position) + + @Suppress("UNCHECKED_CAST") + override fun getFilter(): Filter { + return object : Filter() { + + override fun performFiltering(constraint: CharSequence?): FilterResults { + // Prevent default filtering behaviour + return FilterResults().apply { + values = items + count = items.size + } + } + + override fun publishResults(constraint: CharSequence?, results: FilterResults?) { + @Suppress("UNCHECKED_CAST") + val data = results?.values as? List ?: emptyList() + updateData(data) + } + } + } +} 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 659429a95a..ff73407afa 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 @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 Google LLC + * Copyright 2023-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. @@ -5022,7 +5022,7 @@ class QuestionnaireViewModelTest { DataCaptureConfig( valueSetResolverExternal = object : ExternalAnswerValueSetResolver { - override suspend fun resolve(uri: String): List { + override suspend fun resolve(uri: String, query: String?): List { return if (uri == CODE_SYSTEM_YES_NO) { listOf( Coding().apply { diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/AutoCompleteViewHolderFactoryTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/AutoCompleteViewHolderFactoryTest.kt index 04d63a634b..65c59b34ce 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/AutoCompleteViewHolderFactoryTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/AutoCompleteViewHolderFactoryTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 Google LLC + * Copyright 2023-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.