From 9684f903ffcb7d1acf6bee97c5613d406946a325 Mon Sep 17 00:00:00 2001 From: Elly Kitoto Date: Wed, 17 Sep 2025 20:12:03 +0300 Subject: [PATCH 01/12] Migrate QuestionnaireEditRecyclerview to compose Signed-off-by: Elly Kitoto --- .../fhir/datacapture/QuestionnaireEditItem.kt | 173 ++++++++++++++++++ .../fhir/datacapture/QuestionnaireFragment.kt | 127 ++++++++++--- .../res/layout/questionnaire_fragment.xml | 6 +- 3 files changed, 273 insertions(+), 33 deletions(-) create mode 100644 datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditItem.kt diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditItem.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditItem.kt new file mode 100644 index 0000000000..8a1ea6dde6 --- /dev/null +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditItem.kt @@ -0,0 +1,173 @@ +/* + * 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.datacapture + +import android.view.ViewGroup +import com.google.android.fhir.datacapture.QuestionnaireEditAdapter.Companion.MINIMUM_NUMBER_OF_ANSWER_OPTIONS_FOR_DIALOG +import com.google.android.fhir.datacapture.QuestionnaireEditAdapter.Companion.MINIMUM_NUMBER_OF_ANSWER_OPTIONS_FOR_DROP_DOWN +import com.google.android.fhir.datacapture.contrib.views.PhoneNumberViewHolderFactory +import com.google.android.fhir.datacapture.extensions.itemControl +import com.google.android.fhir.datacapture.extensions.shouldUseDialog +import com.google.android.fhir.datacapture.views.QuestionnaireViewItem +import com.google.android.fhir.datacapture.views.factories.AttachmentViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.AutoCompleteViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.BooleanChoiceViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.CheckBoxGroupViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.DatePickerViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.DateTimePickerViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.DisplayViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.DropDownViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.EditTextDecimalViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.EditTextIntegerViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.EditTextMultiLineViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.EditTextSingleLineViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.GroupViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.QuantityViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemDialogSelectViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder +import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.RadioGroupViewHolderFactory +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 + +fun getQuestionnaireItemViewHolder( + parent: ViewGroup, + questionnaireViewItem: QuestionnaireViewItem, + questionnaireItemViewHolderMatchers: + List, +): QuestionnaireItemViewHolder { + val questionnaireItem = questionnaireViewItem.questionnaireItem + + // Find a matching custom widget + val questionnaireViewHolderFactory = + questionnaireItemViewHolderMatchers.find { it.matches(questionnaireItem) }?.factory + ?: getQuestionnaireItemViewHolderFactory(getItemViewTypeForQuestion(questionnaireViewItem)) + return questionnaireViewHolderFactory.create(parent) +} + +private fun getQuestionnaireItemViewHolderFactory( + questionnaireViewHolderType: QuestionnaireViewHolderType, +): QuestionnaireItemViewHolderFactory { + val viewHolderFactory = + when (questionnaireViewHolderType) { + 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 + QuestionnaireViewHolderType.EDIT_TEXT_INTEGER -> EditTextIntegerViewHolderFactory + QuestionnaireViewHolderType.EDIT_TEXT_DECIMAL -> EditTextDecimalViewHolderFactory + QuestionnaireViewHolderType.RADIO_GROUP -> RadioGroupViewHolderFactory + QuestionnaireViewHolderType.DROP_DOWN -> DropDownViewHolderFactory + QuestionnaireViewHolderType.DISPLAY -> DisplayViewHolderFactory + QuestionnaireViewHolderType.QUANTITY -> QuantityViewHolderFactory + QuestionnaireViewHolderType.CHECK_BOX_GROUP -> CheckBoxGroupViewHolderFactory + QuestionnaireViewHolderType.AUTO_COMPLETE -> AutoCompleteViewHolderFactory + QuestionnaireViewHolderType.DIALOG_SELECT -> QuestionnaireItemDialogSelectViewHolderFactory + QuestionnaireViewHolderType.SLIDER -> SliderViewHolderFactory + QuestionnaireViewHolderType.PHONE_NUMBER -> PhoneNumberViewHolderFactory + QuestionnaireViewHolderType.ATTACHMENT -> AttachmentViewHolderFactory + } + return viewHolderFactory +} + +/** + * Returns the [QuestionnaireViewHolderType] that will be used to render the + * [QuestionnaireViewItem]. This is determined by a combination of the data type of the question and + * any additional Questionnaire Item UI Control Codes + * (http://hl7.org/fhir/R4/valueset-questionnaire-item-control.html) used in the itemControl + * extension (http://hl7.org/fhir/R4/extension-questionnaire-itemcontrol.html). + */ +private fun getItemViewTypeForQuestion( + questionnaireViewItem: QuestionnaireViewItem, +): QuestionnaireViewHolderType { + val questionnaireItem = questionnaireViewItem.questionnaireItem + + if (questionnaireViewItem.enabledAnswerOptions.isNotEmpty()) { + return getChoiceViewHolderType(questionnaireViewItem) + } + + return when (val type = questionnaireItem.type) { + Questionnaire.QuestionnaireItemType.GROUP -> QuestionnaireViewHolderType.GROUP + Questionnaire.QuestionnaireItemType.BOOLEAN -> QuestionnaireViewHolderType.BOOLEAN_TYPE_PICKER + Questionnaire.QuestionnaireItemType.DATE -> QuestionnaireViewHolderType.DATE_PICKER + Questionnaire.QuestionnaireItemType.TIME -> QuestionnaireViewHolderType.TIME_PICKER + Questionnaire.QuestionnaireItemType.DATETIME -> QuestionnaireViewHolderType.DATE_TIME_PICKER + Questionnaire.QuestionnaireItemType.STRING -> getStringViewHolderType(questionnaireViewItem) + Questionnaire.QuestionnaireItemType.TEXT -> QuestionnaireViewHolderType.EDIT_TEXT_MULTI_LINE + Questionnaire.QuestionnaireItemType.INTEGER -> getIntegerViewHolderType(questionnaireViewItem) + Questionnaire.QuestionnaireItemType.DECIMAL -> QuestionnaireViewHolderType.EDIT_TEXT_DECIMAL + Questionnaire.QuestionnaireItemType.CHOICE, + Questionnaire.QuestionnaireItemType.REFERENCE, -> getChoiceViewHolderType(questionnaireViewItem) + Questionnaire.QuestionnaireItemType.DISPLAY -> QuestionnaireViewHolderType.DISPLAY + Questionnaire.QuestionnaireItemType.QUANTITY -> QuestionnaireViewHolderType.QUANTITY + Questionnaire.QuestionnaireItemType.ATTACHMENT -> QuestionnaireViewHolderType.ATTACHMENT + else -> throw NotImplementedError("Question type $type not supported.") + } +} + +private fun getChoiceViewHolderType( + questionnaireViewItem: QuestionnaireViewItem, +): QuestionnaireViewHolderType { + val questionnaireItem = questionnaireViewItem.questionnaireItem + + // 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 + when { + // Always use a dialog for questions with a large number of options + numOptions >= MINIMUM_NUMBER_OF_ANSWER_OPTIONS_FOR_DIALOG -> + QuestionnaireViewHolderType.DIALOG_SELECT + + // Use a check box group if repeated answers are permitted + questionnaireItem.repeats -> QuestionnaireViewHolderType.CHECK_BOX_GROUP + + // Use a dropdown if there are a medium number of options + numOptions >= MINIMUM_NUMBER_OF_ANSWER_OPTIONS_FOR_DROP_DOWN -> + QuestionnaireViewHolderType.DROP_DOWN + + // Use a radio group only if there are a small number of options + else -> QuestionnaireViewHolderType.RADIO_GROUP + } + } +} + +private fun getIntegerViewHolderType( + questionnaireViewItem: QuestionnaireViewItem, +): QuestionnaireViewHolderType { + val questionnaireItem = questionnaireViewItem.questionnaireItem + // Use the view type that the client wants if they specified an itemControl + return questionnaireItem.itemControl?.viewHolderType + ?: QuestionnaireViewHolderType.EDIT_TEXT_INTEGER +} + +private fun getStringViewHolderType( + questionnaireViewItem: QuestionnaireViewItem, +): QuestionnaireViewHolderType { + val questionnaireItem = questionnaireViewItem.questionnaireItem + // Use the view type that the client wants if they specified an itemControl + return questionnaireItem.itemControl?.viewHolderType + ?: QuestionnaireViewHolderType.EDIT_TEXT_SINGLE_LINE +} diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt index 590339b5e7..57b9e8ab32 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt @@ -21,9 +21,23 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.LinearLayout import android.widget.TextView import androidx.annotation.VisibleForTesting import androidx.appcompat.view.ContextThemeWrapper +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.res.use import androidx.core.os.bundleOf import androidx.fragment.app.Fragment @@ -33,9 +47,11 @@ import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import com.google.android.fhir.datacapture.extensions.inflate import com.google.android.fhir.datacapture.validation.Invalid import com.google.android.fhir.datacapture.views.NavigationViewHolder import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.RepeatedGroupHeaderItemViewHolder import com.google.android.material.progressindicator.LinearProgressIndicator import kotlinx.coroutines.launch import org.hl7.fhir.r4.model.Questionnaire @@ -91,8 +107,8 @@ class QuestionnaireFragment : Fragment() { /** @suppress */ override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - val questionnaireEditRecyclerView = - view.findViewById(R.id.questionnaire_edit_recycler_view) + val questionnaireEditComposeView = + view.findViewById(R.id.questionnaire_edit_compose_view) val questionnaireReviewRecyclerView = view.findViewById(R.id.questionnaire_review_recycler_view) val questionnaireTitle = view.findViewById(R.id.questionnaire_title) @@ -137,8 +153,7 @@ class QuestionnaireFragment : Fragment() { } val questionnaireProgressIndicator: LinearProgressIndicator = view.findViewById(R.id.questionnaire_progress_indicator) - val questionnaireEditAdapter = - QuestionnaireEditAdapter(questionnaireItemViewHolderFactoryMatchersProvider.get()) + val questionnaireReviewAdapter = QuestionnaireReviewAdapter() val reviewModeEditButton = @@ -146,12 +161,6 @@ class QuestionnaireFragment : Fragment() { setOnClickListener { viewModel.setReviewMode(false) } } - questionnaireEditRecyclerView.adapter = questionnaireEditAdapter - val linearLayoutManager = LinearLayoutManager(view.context) - questionnaireEditRecyclerView.layoutManager = linearLayoutManager - // Animation does work well with views that could gain focus - questionnaireEditRecyclerView.itemAnimator = null - questionnaireReviewRecyclerView.adapter = questionnaireReviewAdapter questionnaireReviewRecyclerView.layoutManager = LinearLayoutManager(view.context) @@ -161,7 +170,7 @@ class QuestionnaireFragment : Fragment() { when (val displayMode = state.displayMode) { is DisplayMode.ReviewMode -> { // Set items - questionnaireEditRecyclerView.visibility = View.GONE + questionnaireEditComposeView.visibility = View.GONE questionnaireReviewAdapter.submitList( state.items, ) @@ -190,8 +199,21 @@ class QuestionnaireFragment : Fragment() { is DisplayMode.EditMode -> { // Set items questionnaireReviewRecyclerView.visibility = View.GONE - questionnaireEditAdapter.submitList(state.items) - questionnaireEditRecyclerView.visibility = View.VISIBLE + questionnaireEditComposeView.setContent { + val questionerStateFlow = viewModel.questionnaireStateFlow.collectAsState() + QuestionnaireEditList( + questionerStateFlow, + onUpdateProgressIndicator = { currentPage, totalCount -> + questionnaireProgressIndicator.updateProgressIndicator( + calculateProgressPercentage( + count = (currentPage + 1), + totalCount = totalCount, + ), + ) + } + ) + } + questionnaireEditComposeView.visibility = View.VISIBLE reviewModeEditButton.visibility = View.GONE questionnaireTitle.visibility = View.GONE @@ -215,27 +237,11 @@ class QuestionnaireFragment : Fragment() { totalCount = displayMode.pagination.pages.size, ), ) - } else { - questionnaireEditRecyclerView.addOnScrollListener( - object : RecyclerView.OnScrollListener() { - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - super.onScrolled(recyclerView, dx, dy) - questionnaireProgressIndicator.updateProgressIndicator( - calculateProgressPercentage( - count = - (linearLayoutManager.findLastVisibleItemPosition() + - 1), // incremented by 1 due to findLastVisiblePosition() starts with 0. - totalCount = linearLayoutManager.itemCount, - ), - ) - } - }, - ) } } is DisplayMode.InitMode -> { questionnaireReviewRecyclerView.visibility = View.GONE - questionnaireEditRecyclerView.visibility = View.GONE + questionnaireEditComposeView.visibility = View.GONE questionnaireProgressIndicator.visibility = View.GONE reviewModeEditButton.visibility = View.GONE bottomNavContainerFrame.visibility = View.GONE @@ -283,6 +289,67 @@ class QuestionnaireFragment : Fragment() { } } + @Composable + private fun QuestionnaireEditList( + questionerStateFlow: State, + onUpdateProgressIndicator: (Int, Int) -> Unit, + ) { + val listState = rememberLazyListState() + LaunchedEffect(listState) { + snapshotFlow { listState.firstVisibleItemIndex } + .collect { firstIndex -> + onUpdateProgressIndicator(firstIndex, listState.layoutInfo.totalItemsCount) + } + } + LazyColumn(state = listState) { + items( + questionerStateFlow.value.items, + key = { item -> + when (item) { + is QuestionnaireAdapterItem.Question -> item.item.questionnaireItem.linkId + is QuestionnaireAdapterItem.RepeatedGroupHeader -> "repeated-group-header-${item.index}" + is QuestionnaireAdapterItem.Navigation -> "navigation" + } + }, + ) { adapterItem: QuestionnaireAdapterItem -> + AndroidView( + factory = { context -> + val linearLayout = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } + + when (adapterItem) { + is QuestionnaireAdapterItem.Question -> { + val viewHolder = + getQuestionnaireItemViewHolder( + parent = linearLayout, + questionnaireViewItem = adapterItem.item, + questionnaireItemViewHolderMatchers = + questionnaireItemViewHolderFactoryMatchersProvider.get(), + ) + viewHolder.bind(adapterItem.item) + linearLayout.apply { addView(viewHolder.itemView) } + } + is QuestionnaireAdapterItem.Navigation -> { + val viewHolder = + NavigationViewHolder(linearLayout.inflate(R.layout.pagination_navigation_view)) + viewHolder.bind(adapterItem.questionnaireNavigationUIState) + linearLayout.apply { addView(viewHolder.itemView) } + } + is QuestionnaireAdapterItem.RepeatedGroupHeader -> { + val viewHolder = + RepeatedGroupHeaderItemViewHolder( + linearLayout.inflate(R.layout.repeated_group_instance_header_view), + ) + viewHolder.bind(adapterItem) + linearLayout.apply { addView(viewHolder.itemView) } + } + } + }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + /** Calculates the progress percentage from given [count] and [totalCount] values. */ internal fun calculateProgressPercentage(count: Int, totalCount: Int): Int { return if (totalCount == 0) 0 else (count * 100 / totalCount) diff --git a/datacapture/src/main/res/layout/questionnaire_fragment.xml b/datacapture/src/main/res/layout/questionnaire_fragment.xml index 986e51e74d..5597433bb6 100644 --- a/datacapture/src/main/res/layout/questionnaire_fragment.xml +++ b/datacapture/src/main/res/layout/questionnaire_fragment.xml @@ -60,14 +60,14 @@ style="?attr/questionnaireLinearProgressIndicatorStyle" android:layout_width="match_parent" android:layout_height="wrap_content" - app:layout_constraintBottom_toTopOf="@id/questionnaire_edit_recycler_view" + app:layout_constraintBottom_toTopOf="@id/questionnaire_edit_compose_view" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/questionnaire_title_layout" /> - Date: Thu, 18 Sep 2025 11:22:33 +0300 Subject: [PATCH 02/12] Fix formatting Signed-off-by: Elly Kitoto --- .../google/android/fhir/datacapture/QuestionnaireFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt index 57b9e8ab32..68b1248fab 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt @@ -210,7 +210,7 @@ class QuestionnaireFragment : Fragment() { totalCount = totalCount, ), ) - } + }, ) } questionnaireEditComposeView.visibility = View.VISIBLE From 0992f027ecdb8e620322b0b474d5e65ec373bc2f Mon Sep 17 00:00:00 2001 From: Elly Kitoto Date: Mon, 29 Sep 2025 15:13:17 +0300 Subject: [PATCH 03/12] Fix questionnaire edit view rendering in lazycolumn - Fixed duplicate ids when using repeated groups - Fixed issues with progress indicator upon scroll - Fixed issues with showing validation errors - Fixed issues with state management on individual lazy column rows that renders form widgets. Tag the created views with the viewholder, and call viewholder bind method to update view content. Signed-off-by: Elly Kitoto --- .../datacapture/QuestionnaireAdapterItem.kt | 7 +- .../fhir/datacapture/QuestionnaireEditItem.kt | 6 +- .../fhir/datacapture/QuestionnaireFragment.kt | 111 ++++++++++++------ .../datacapture/QuestionnaireViewModel.kt | 21 +++- .../views/QuestionnaireViewItem.kt | 2 +- datacapture/src/main/res/values/ids.xml | 4 + 6 files changed, 106 insertions(+), 45 deletions(-) create mode 100644 datacapture/src/main/res/values/ids.xml diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireAdapterItem.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireAdapterItem.kt index 5b97c29b62..b5c48ce492 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireAdapterItem.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireAdapterItem.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. @@ -22,10 +22,13 @@ import org.hl7.fhir.r4.model.QuestionnaireResponse /** Various types of rows that can be used in a Questionnaire RecyclerView. */ internal sealed interface QuestionnaireAdapterItem { /** A row for a question in a Questionnaire RecyclerView. */ - data class Question(val item: QuestionnaireViewItem) : QuestionnaireAdapterItem + data class Question(val item: QuestionnaireViewItem) : QuestionnaireAdapterItem { + var id: String = item.questionnaireItem.linkId + } /** A row for a repeated group response instance's header. */ data class RepeatedGroupHeader( + val id: String, /** The response index. This is 0-indexed, but should be 1-indexed when rendered in the UI. */ val index: Int, /** Callback that is invoked when the user clicks the delete button. */ diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditItem.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditItem.kt index 8a1ea6dde6..682c5c5527 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditItem.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditItem.kt @@ -51,11 +51,11 @@ fun getQuestionnaireItemViewHolder( questionnaireItemViewHolderMatchers: List, ): QuestionnaireItemViewHolder { - val questionnaireItem = questionnaireViewItem.questionnaireItem - // Find a matching custom widget val questionnaireViewHolderFactory = - questionnaireItemViewHolderMatchers.find { it.matches(questionnaireItem) }?.factory + questionnaireItemViewHolderMatchers + .find { it.matches(questionnaireViewItem.questionnaireItem) } + ?.factory ?: getQuestionnaireItemViewHolderFactory(getItemViewTypeForQuestion(questionnaireViewItem)) return questionnaireViewHolderFactory.create(parent) } diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt index 68b1248fab..8f99a3c2fa 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt @@ -33,13 +33,14 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.res.use import androidx.core.os.bundleOf +import androidx.core.view.ViewCompat import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.setFragmentResult @@ -50,9 +51,11 @@ import androidx.recyclerview.widget.RecyclerView import com.google.android.fhir.datacapture.extensions.inflate import com.google.android.fhir.datacapture.validation.Invalid import com.google.android.fhir.datacapture.views.NavigationViewHolder +import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolderFactory import com.google.android.fhir.datacapture.views.factories.RepeatedGroupHeaderItemViewHolder import com.google.android.material.progressindicator.LinearProgressIndicator +import kotlin.uuid.ExperimentalUuidApi import kotlinx.coroutines.launch import org.hl7.fhir.r4.model.Questionnaire import timber.log.Timber @@ -289,62 +292,102 @@ class QuestionnaireFragment : Fragment() { } } + @OptIn(ExperimentalUuidApi::class) @Composable private fun QuestionnaireEditList( questionerStateFlow: State, onUpdateProgressIndicator: (Int, Int) -> Unit, ) { val listState = rememberLazyListState() + val currentDisplayMode = remember { questionerStateFlow.value.displayMode } + LaunchedEffect(listState) { - snapshotFlow { listState.firstVisibleItemIndex } - .collect { firstIndex -> - onUpdateProgressIndicator(firstIndex, listState.layoutInfo.totalItemsCount) - } + if ( + currentDisplayMode is DisplayMode.EditMode && !currentDisplayMode.pagination.isPaginated + ) { + snapshotFlow { + val layoutInfo = listState.layoutInfo + val visibleItems = layoutInfo.visibleItemsInfo + val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + val total = layoutInfo.totalItemsCount + + // If all items are visible, we're at 100% + if (visibleItems.size >= total && total > 0) { + total to total + } else { + lastVisible + 1 to total + } + } + .collect { (visibleCount, total) -> onUpdateProgressIndicator(visibleCount, total) } + } } LazyColumn(state = listState) { items( questionerStateFlow.value.items, key = { item -> when (item) { - is QuestionnaireAdapterItem.Question -> item.item.questionnaireItem.linkId - is QuestionnaireAdapterItem.RepeatedGroupHeader -> "repeated-group-header-${item.index}" + is QuestionnaireAdapterItem.Question -> item.id + is QuestionnaireAdapterItem.RepeatedGroupHeader -> item.id is QuestionnaireAdapterItem.Navigation -> "navigation" } }, ) { adapterItem: QuestionnaireAdapterItem -> AndroidView( factory = { context -> - val linearLayout = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } - - when (adapterItem) { - is QuestionnaireAdapterItem.Question -> { - val viewHolder = - getQuestionnaireItemViewHolder( - parent = linearLayout, - questionnaireViewItem = adapterItem.item, - questionnaireItemViewHolderMatchers = - questionnaireItemViewHolderFactoryMatchersProvider.get(), - ) - viewHolder.bind(adapterItem.item) - linearLayout.apply { addView(viewHolder.itemView) } - } - is QuestionnaireAdapterItem.Navigation -> { - val viewHolder = - NavigationViewHolder(linearLayout.inflate(R.layout.pagination_navigation_view)) - viewHolder.bind(adapterItem.questionnaireNavigationUIState) - linearLayout.apply { addView(viewHolder.itemView) } - } - is QuestionnaireAdapterItem.RepeatedGroupHeader -> { - val viewHolder = - RepeatedGroupHeaderItemViewHolder( - linearLayout.inflate(R.layout.repeated_group_instance_header_view), - ) - viewHolder.bind(adapterItem) - linearLayout.apply { addView(viewHolder.itemView) } + LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + ViewCompat.setNestedScrollingEnabled(this, false) + // Build the view using viewHolder factories. To keep the viewHolder accessible + // across recompositions, each created view is tagged with its viewHolder. + // On recomposition, the views are not recreated—instead, their content is + // refreshed by calling viewHolder#bind. + when (adapterItem) { + is QuestionnaireAdapterItem.Question -> { + val viewHolder = + getQuestionnaireItemViewHolder( + parent = this, + questionnaireViewItem = adapterItem.item, + questionnaireItemViewHolderMatchers = + questionnaireItemViewHolderFactoryMatchersProvider.get(), + ) + viewHolder.bind(adapterItem.item) + setTag(R.id.question_view_holder, viewHolder) + addView(viewHolder.itemView) + } + is QuestionnaireAdapterItem.Navigation -> { + val viewHolder = + NavigationViewHolder(inflate(R.layout.pagination_navigation_view)) + viewHolder.bind(adapterItem.questionnaireNavigationUIState) + setTag(R.id.question_view_holder, viewHolder) + addView(viewHolder.itemView) + } + is QuestionnaireAdapterItem.RepeatedGroupHeader -> { + val viewHolder = + RepeatedGroupHeaderItemViewHolder( + inflate(R.layout.repeated_group_instance_header_view), + ) + viewHolder.bind(adapterItem) + setTag(R.id.question_view_holder, viewHolder) + addView(viewHolder.itemView) + } } } }, modifier = Modifier.fillMaxWidth(), + update = { view -> + val viewHolderTag = view.getTag(R.id.question_view_holder) + when (viewHolderTag) { + is QuestionnaireItemViewHolder -> + viewHolderTag.bind((adapterItem as QuestionnaireAdapterItem.Question).item) + is NavigationViewHolder -> + viewHolderTag.bind( + (adapterItem as QuestionnaireAdapterItem.Navigation) + .questionnaireNavigationUIState, + ) + is RepeatedGroupHeaderItemViewHolder -> + viewHolderTag.bind((adapterItem as QuestionnaireAdapterItem.RepeatedGroupHeader)) + } + }, ) } } 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 e96bd28a50..4ff6af22f0 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 @@ -1008,6 +1008,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat // Case 3 add( QuestionnaireAdapterItem.RepeatedGroupHeader( + id = "${index}_${question.item.questionnaireItem.linkId}", index = index, onDeleteClicked = { viewModelScope.launch { question.item.removeAnswerAt(index) } }, responses = nestedResponseItemList, @@ -1017,11 +1018,21 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat } addAll( getQuestionnaireAdapterItems( - // If nested display item is identified as instructions or flyover, then do not create - // questionnaire state for it. - questionnaireItemList = questionnaireItem.item.filterNot { it.isDisplayItem }, - questionnaireResponseItemList = nestedResponseItemList, - ), + // If nested display item is identified as instructions or flyover, then do not + // create + // questionnaire state for it. + questionnaireItemList = questionnaireItem.item.filterNot { it.isDisplayItem }, + questionnaireResponseItemList = nestedResponseItemList, + ) + .onEach { + // Reset the question id to avoid duplicate keys in LazyColumn composable. The new + // id is derived from the the repeated group index, the parent question + // questionnaire item linkId and the linkId of the nested questions + if (it is QuestionnaireAdapterItem.Question) { + it.id = + "${index}_${question.item.questionnaireItem.linkId}_${it.item.questionnaireItem.linkId}" + } + }, ) } } 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..8a9a85f020 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. diff --git a/datacapture/src/main/res/values/ids.xml b/datacapture/src/main/res/values/ids.xml new file mode 100644 index 0000000000..c51513710d --- /dev/null +++ b/datacapture/src/main/res/values/ids.xml @@ -0,0 +1,4 @@ + + + + From a3f918145d13958bab472bad0301040c15098e78 Mon Sep 17 00:00:00 2001 From: Elly Kitoto Date: Mon, 29 Sep 2025 15:50:59 +0300 Subject: [PATCH 04/12] Throw exception if questionnaire field linkId is not provided Every field in a questionnaire is expected to have a unique linkId. Signed-off-by: Elly Kitoto --- .../google/android/fhir/datacapture/QuestionnaireAdapterItem.kt | 2 +- .../google/android/fhir/datacapture/QuestionnaireFragment.kt | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireAdapterItem.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireAdapterItem.kt index b5c48ce492..6dbe5a2d10 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireAdapterItem.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireAdapterItem.kt @@ -23,7 +23,7 @@ import org.hl7.fhir.r4.model.QuestionnaireResponse internal sealed interface QuestionnaireAdapterItem { /** A row for a question in a Questionnaire RecyclerView. */ data class Question(val item: QuestionnaireViewItem) : QuestionnaireAdapterItem { - var id: String = item.questionnaireItem.linkId + var id: String? = item.questionnaireItem.linkId } /** A row for a repeated group response instance's header. */ diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt index 8f99a3c2fa..6bf779e987 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt @@ -327,6 +327,7 @@ class QuestionnaireFragment : Fragment() { key = { item -> when (item) { is QuestionnaireAdapterItem.Question -> item.id + ?: throw IllegalStateException("Missing id for the QuestionnaireAdapterItem: $item") is QuestionnaireAdapterItem.RepeatedGroupHeader -> item.id is QuestionnaireAdapterItem.Navigation -> "navigation" } From 670379eb07ff4364764fe3d00d909ffb7ab4e4c4 Mon Sep 17 00:00:00 2001 From: Elly Kitoto Date: Thu, 2 Oct 2025 15:54:34 +0300 Subject: [PATCH 05/12] Update tests Signed-off-by: Elly Kitoto --- .../test/QuestionnaireUiEspressoTest.kt | 282 ++++++++---------- .../fhir/datacapture/QuestionnaireFragment.kt | 6 +- 2 files changed, 137 insertions(+), 151 deletions(-) diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/QuestionnaireUiEspressoTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/QuestionnaireUiEspressoTest.kt index 3ca8eb1b21..dbe9a8b9ae 100644 --- a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/QuestionnaireUiEspressoTest.kt +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/QuestionnaireUiEspressoTest.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. @@ -19,22 +19,20 @@ package com.google.android.fhir.datacapture.test import android.view.View import android.widget.FrameLayout import android.widget.TextView +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.printToLog import androidx.fragment.app.commitNow -import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.UiController -import androidx.test.espresso.ViewAction import androidx.test.espresso.action.ViewActions import androidx.test.espresso.action.ViewActions.typeText import androidx.test.espresso.assertion.ViewAssertions -import androidx.test.espresso.contrib.RecyclerViewActions import androidx.test.espresso.matcher.RootMatchers import androidx.test.espresso.matcher.ViewMatchers -import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText -import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SdkSuppress import androidx.test.platform.app.InstrumentationRegistry @@ -73,10 +71,7 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class QuestionnaireUiEspressoTest { - @Rule - @JvmField - var activityScenarioRule: ActivityScenarioRule = - ActivityScenarioRule(TestActivity::class.java) + @get:Rule(order = 9) val composeTestRule = createAndroidComposeRule() private lateinit var parent: FrameLayout private val parser: IParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() @@ -84,14 +79,14 @@ class QuestionnaireUiEspressoTest { @Before fun setup() { - activityScenarioRule.scenario.onActivity { activity -> parent = FrameLayout(activity) } + composeTestRule.activityRule.scenario.onActivity { activity -> parent = FrameLayout(activity) } } @Test fun shouldDisplayReviewButtonWhenNoMorePagesToDisplay() { buildFragmentFromQuestionnaire("/paginated_questionnaire_with_dependent_answer.json", true) - onView(withId(R.id.review_mode_button)) + onView(withId(com.google.android.fhir.datacapture.R.id.review_mode_button)) .check( ViewAssertions.matches( ViewMatchers.withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE), @@ -99,13 +94,13 @@ class QuestionnaireUiEspressoTest { ) clickOnText("Yes") - onView(withId(R.id.review_mode_button)) + onView(withId(com.google.android.fhir.datacapture.R.id.review_mode_button)) .check( ViewAssertions.matches(ViewMatchers.withEffectiveVisibility(ViewMatchers.Visibility.GONE)), ) clickOnText("No") - onView(withId(R.id.review_mode_button)) + onView(withId(com.google.android.fhir.datacapture.R.id.review_mode_button)) .check( ViewAssertions.matches( ViewMatchers.withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE), @@ -119,7 +114,7 @@ class QuestionnaireUiEspressoTest { clickOnText("Next") - onView(withId(R.id.pagination_next_button)) + onView(withId(com.google.android.fhir.datacapture.R.id.pagination_next_button)) .check( ViewAssertions.matches(ViewMatchers.withEffectiveVisibility(ViewMatchers.Visibility.GONE)), ) @@ -129,7 +124,7 @@ class QuestionnaireUiEspressoTest { fun shouldDisplayNextButtonIfEnabled() { buildFragmentFromQuestionnaire("/layout_paginated.json", true) - onView(withId(R.id.pagination_next_button)) + onView(withId(com.google.android.fhir.datacapture.R.id.pagination_next_button)) .check( ViewAssertions.matches( ViewMatchers.withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE), @@ -141,8 +136,9 @@ class QuestionnaireUiEspressoTest { fun integerTextEdit_inputOutOfRange_shouldShowError() { buildFragmentFromQuestionnaire("/text_questionnaire_integer.json") - onView(withId(R.id.text_input_edit_text)).perform(typeText("12345678901")) - onView(withId(R.id.text_input_layout)).check { view, _ -> + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_edit_text)) + .perform(typeText("12345678901")) + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_layout)).check { view, _ -> val actualError = (view as TextInputLayout).error assertThat(actualError).isEqualTo("Number must be between -2,147,483,648 and 2,147,483,647") } @@ -156,15 +152,18 @@ class QuestionnaireUiEspressoTest { buildFragmentFromQuestionnaire("/text_questionnaire_integer.json") runTest { - onView(withId(R.id.text_input_edit_text)).perform(typeText("0")) + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_edit_text)) + .perform(typeText("0")) assertThat(getQuestionnaireResponse().item.first().answer.first().valueIntegerType.value) .isEqualTo(0) - onView(withId(R.id.text_input_edit_text)).perform(typeText("01")) + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_edit_text)) + .perform(typeText("01")) assertThat(getQuestionnaireResponse().item.first().answer.first().valueIntegerType.value) .isEqualTo(1) - onView(withId(R.id.text_input_edit_text)).check { view, _ -> + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_edit_text)).check { view, _ + -> assertThat((view as TextInputEditText).text.toString()).isEqualTo("001") } @@ -178,15 +177,18 @@ class QuestionnaireUiEspressoTest { buildFragmentFromQuestionnaire("/text_questionnaire_decimal.json") runTest { - onView(withId(R.id.text_input_edit_text)).perform(typeText("0.")) + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_edit_text)) + .perform(typeText("0.")) assertThat(getQuestionnaireResponse().item.first().answer.first().valueDecimalType.value) .isEqualTo(BigDecimal.valueOf(0.0)) - onView(withId(R.id.text_input_edit_text)).perform(typeText("01")) + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_edit_text)) + .perform(typeText("01")) assertThat(getQuestionnaireResponse().item.first().answer.first().valueDecimalType.value) .isEqualTo(BigDecimal.valueOf(0.01)) - onView(withId(R.id.text_input_edit_text)).check { view, _ -> + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_edit_text)).check { view, _ + -> assertThat((view as TextInputEditText).text.toString()).isEqualTo("0.01") } @@ -199,9 +201,10 @@ class QuestionnaireUiEspressoTest { fun decimalTextEdit_typingInvalidTextShouldShowError() { buildFragmentFromQuestionnaire("/text_questionnaire_decimal.json") - onView(withId(R.id.text_input_edit_text)).perform(typeText("1.1.1.1")) + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_edit_text)) + .perform(typeText("1.1.1.1")) - onView(withId(R.id.text_input_layout)).check { view, _ -> + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_layout)).check { view, _ -> assertThat((view as TextInputLayout).error).isEqualTo("Invalid number") } } @@ -211,31 +214,35 @@ class QuestionnaireUiEspressoTest { buildFragmentFromQuestionnaire("/component_date_time_picker.json") // Add month and day. No need to add slashes as they are added automatically - onView(withId(R.id.date_input_edit_text)) + onView(withId(com.google.android.fhir.datacapture.R.id.date_input_edit_text)) .perform(ViewActions.click()) .perform(ViewActions.typeTextIntoFocusedView("0105")) - onView(withId(R.id.date_input_layout)).check { view, _ -> + onView(withId(com.google.android.fhir.datacapture.R.id.date_input_layout)).check { view, _ -> val actualError = (view as TextInputLayout).error assertThat(actualError).isEqualTo("Date format needs to be mm/dd/yyyy (e.g. 01/31/2023)") } - onView(withId(R.id.time_input_layout)).check { view, _ -> assertThat(view.isEnabled).isFalse() } + onView(withId(com.google.android.fhir.datacapture.R.id.time_input_layout)).check { view, _ -> + assertThat(view.isEnabled).isFalse() + } } @Test fun dateTimePicker_shouldEnableTimePickerWithCorrectDate_butNotSaveInQuestionnaireResponse() { buildFragmentFromQuestionnaire("/component_date_time_picker.json") - onView(withId(R.id.date_input_edit_text)) + onView(withId(com.google.android.fhir.datacapture.R.id.date_input_edit_text)) .perform(ViewActions.click()) .perform(ViewActions.typeTextIntoFocusedView("01052005")) - onView(withId(R.id.date_input_layout)).check { view, _ -> + onView(withId(com.google.android.fhir.datacapture.R.id.date_input_layout)).check { view, _ -> val actualError = (view as TextInputLayout).error assertThat(actualError).isEqualTo(null) } - onView(withId(R.id.time_input_layout)).check { view, _ -> assertThat(view.isEnabled).isTrue() } + onView(withId(com.google.android.fhir.datacapture.R.id.time_input_layout)).check { view, _ -> + assertThat(view.isEnabled).isTrue() + } runTest { assertThat(getQuestionnaireResponse().item.size).isEqualTo(1) @@ -247,11 +254,12 @@ class QuestionnaireUiEspressoTest { fun dateTimePicker_shouldSetAnswerWhenDateAndTimeAreFilled() { buildFragmentFromQuestionnaire("/component_date_time_picker.json") - onView(withId(R.id.date_input_edit_text)) + onView(withId(com.google.android.fhir.datacapture.R.id.date_input_edit_text)) .perform(ViewActions.click()) .perform(ViewActions.typeTextIntoFocusedView("01052005")) - onView(withId(R.id.time_input_layout)).perform(clickIcon(true)) + onView(withId(com.google.android.fhir.datacapture.R.id.time_input_layout)) + .perform(clickIcon(true)) clickOnText("AM") clickOnText("6") clickOnText("10") @@ -269,11 +277,11 @@ class QuestionnaireUiEspressoTest { buildFragmentFromQuestionnaire("/component_date_picker.json") // Add month and day. No need to add slashes as they are added automatically - onView(withId(R.id.text_input_edit_text)) + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_edit_text)) .perform(ViewActions.click()) .perform(ViewActions.typeTextIntoFocusedView("0105")) - onView(withId(R.id.text_input_layout)).check { view, _ -> + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_layout)).check { view, _ -> val actualError = (view as TextInputLayout).error assertThat(actualError).isEqualTo("Date format needs to be mm/dd/yyyy (e.g. 01/31/2023)") } @@ -283,11 +291,11 @@ class QuestionnaireUiEspressoTest { fun datePicker_shouldSaveInQuestionnaireResponseWhenCorrectDateEntered() { buildFragmentFromQuestionnaire("/component_date_picker.json") - onView(withId(R.id.text_input_edit_text)) + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_edit_text)) .perform(ViewActions.click()) .perform(ViewActions.typeTextIntoFocusedView("01052005")) - onView(withId(R.id.text_input_layout)).check { view, _ -> + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_layout)).check { view, _ -> val actualError = (view as TextInputLayout).error assertThat(actualError).isEqualTo(null) } @@ -322,8 +330,9 @@ class QuestionnaireUiEspressoTest { } buildFragmentFromQuestionnaire(questionnaire) - onView(withId(R.id.text_input_layout)).perform(clickIcon(true)) - onView(CoreMatchers.allOf(ViewMatchers.withText("OK"))) + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_layout)) + .perform(clickIcon(true)) + onView(CoreMatchers.allOf(withText("OK"))) .inRoot(RootMatchers.isDialog()) .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) .perform(ViewActions.click()) @@ -369,8 +378,9 @@ class QuestionnaireUiEspressoTest { } buildFragmentFromQuestionnaire(questionnaire) - onView(withId(R.id.text_input_layout)).perform(clickIcon(true)) - onView(CoreMatchers.allOf(ViewMatchers.withText("OK"))) + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_layout)) + .perform(clickIcon(true)) + onView(CoreMatchers.allOf(withText("OK"))) .inRoot(RootMatchers.isDialog()) .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) .perform(ViewActions.click()) @@ -416,8 +426,9 @@ class QuestionnaireUiEspressoTest { } buildFragmentFromQuestionnaire(questionnaire) - onView(withId(R.id.text_input_layout)).perform(clickIcon(true)) - onView(CoreMatchers.allOf(ViewMatchers.withText("OK"))) + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_layout)) + .perform(clickIcon(true)) + onView(CoreMatchers.allOf(withText("OK"))) .inRoot(RootMatchers.isDialog()) .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) .perform(ViewActions.click()) @@ -468,7 +479,7 @@ class QuestionnaireUiEspressoTest { Assert.assertThrows(IllegalArgumentException::class.java) { onView(withId(com.google.android.fhir.datacapture.R.id.text_input_layout)) .perform(clickIcon(true)) - onView(CoreMatchers.allOf(ViewMatchers.withText("OK"))) + onView(CoreMatchers.allOf(withText("OK"))) .inRoot(RootMatchers.isDialog()) .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) .perform(ViewActions.click()) @@ -480,32 +491,35 @@ class QuestionnaireUiEspressoTest { fun displayItems_shouldGetEnabled_withAnswerChoice() { buildFragmentFromQuestionnaire("/questionnaire_with_enabled_display_items.json") - onView(withId(R.id.hint)).check { view, _ -> + onView(withId(com.google.android.fhir.datacapture.R.id.hint)).check { view, _ -> val hintVisibility = (view as TextView).visibility assertThat(hintVisibility).isEqualTo(View.GONE) } - onView(withId(R.id.yes_radio_button)).perform(ViewActions.click()) + onView(withId(com.google.android.fhir.datacapture.R.id.yes_radio_button)) + .perform(ViewActions.click()) - onView(withId(R.id.hint)).check { view, _ -> + onView(withId(com.google.android.fhir.datacapture.R.id.hint)).check { view, _ -> val hintVisibility = (view as TextView).visibility val hintText = view.text.toString() assertThat(hintVisibility).isEqualTo(View.VISIBLE) assertThat(hintText).isEqualTo("Text when yes is selected") } - onView(withId(R.id.no_radio_button)).perform(ViewActions.click()) + onView(withId(com.google.android.fhir.datacapture.R.id.no_radio_button)) + .perform(ViewActions.click()) - onView(withId(R.id.hint)).check { view, _ -> + onView(withId(com.google.android.fhir.datacapture.R.id.hint)).check { view, _ -> val hintVisibility = (view as TextView).visibility val hintText = view.text.toString() assertThat(hintVisibility).isEqualTo(View.VISIBLE) assertThat(hintText).isEqualTo("Text when no is selected") } - onView(withId(R.id.no_radio_button)).perform(ViewActions.click()) + onView(withId(com.google.android.fhir.datacapture.R.id.no_radio_button)) + .perform(ViewActions.click()) - onView(withId(R.id.hint)).check { view, _ -> + onView(withId(com.google.android.fhir.datacapture.R.id.hint)).check { view, _ -> val hintVisibility = (view as TextView).visibility assertThat(hintVisibility).isEqualTo(View.GONE) } @@ -516,7 +530,7 @@ class QuestionnaireUiEspressoTest { buildFragmentFromQuestionnaire("/questionnaire_with_dynamic_question_text.json") onView(CoreMatchers.allOf(withText("Option Date"))).check { view, _ -> - assertThat(view.id).isEqualTo(R.id.question) + assertThat(view.id).isEqualTo(com.google.android.fhir.datacapture.R.id.question) } onView(CoreMatchers.allOf(withText("Provide \"First Option\" Date"))).check { view, _ -> @@ -530,7 +544,7 @@ class QuestionnaireUiEspressoTest { } onView(CoreMatchers.allOf(withText("Provide \"First Option\" Date"))).check { view, _ -> - assertThat(view.id).isEqualTo(R.id.question) + assertThat(view.id).isEqualTo(com.google.android.fhir.datacapture.R.id.question) } } @@ -539,13 +553,13 @@ class QuestionnaireUiEspressoTest { fun clearAllAnswers_shouldClearDraftAnswer() { val questionnaireFragment = buildFragmentFromQuestionnaire("/component_date_picker.json") // Add month and day. No need to add slashes as they are added automatically - onView(withId(R.id.text_input_edit_text)) + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_edit_text)) .perform(ViewActions.click()) .perform(ViewActions.typeTextIntoFocusedView("0105")) questionnaireFragment.clearAllAnswers() - onView(withId(R.id.text_input_edit_text)).check { view, _ -> + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_edit_text)).check { view, _ -> assertThat((view as TextInputEditText).text.toString()).isEmpty() } } @@ -554,88 +568,87 @@ class QuestionnaireUiEspressoTest { fun progressBar_shouldBeVisible_withSinglePageQuestionnaire() { buildFragmentFromQuestionnaire("/text_questionnaire_integer.json") - onView(withId(R.id.questionnaire_progress_indicator)).check { view, _ -> - val linearProgressIndicator = (view as LinearProgressIndicator) - assertThat(linearProgressIndicator.visibility).isEqualTo(View.VISIBLE) - assertThat(linearProgressIndicator.progress).isEqualTo(100) - } + onView(withId(com.google.android.fhir.datacapture.R.id.questionnaire_progress_indicator)) + .check { view, _ -> + val linearProgressIndicator = (view as LinearProgressIndicator) + assertThat(linearProgressIndicator.visibility).isEqualTo(View.VISIBLE) + assertThat(linearProgressIndicator.progress).isEqualTo(100) + } } @Test fun progressBar_shouldBeVisible_withPaginatedQuestionnaire() { buildFragmentFromQuestionnaire("/layout_paginated.json") - onView(withId(R.id.questionnaire_progress_indicator)).check { view, _ -> - val linearProgressIndicator = (view as LinearProgressIndicator) - assertThat(linearProgressIndicator.visibility).isEqualTo(View.VISIBLE) - assertThat(linearProgressIndicator.progress).isEqualTo(50) - } + onView(withId(com.google.android.fhir.datacapture.R.id.questionnaire_progress_indicator)) + .check { view, _ -> + val linearProgressIndicator = (view as LinearProgressIndicator) + assertThat(linearProgressIndicator.visibility).isEqualTo(View.VISIBLE) + assertThat(linearProgressIndicator.progress).isEqualTo(50) + } } @Test fun progressBar_shouldProgress_onPaginationNext() { buildFragmentFromQuestionnaire("/layout_paginated.json") - onView(withId(R.id.pagination_next_button)).perform(ViewActions.click()) + onView(withId(com.google.android.fhir.datacapture.R.id.pagination_next_button)) + .perform(ViewActions.click()) - onView(withId(R.id.questionnaire_progress_indicator)).check { view, _ -> - val linearProgressIndicator = (view as LinearProgressIndicator) - assertThat(linearProgressIndicator.progress).isEqualTo(100) - } + onView(withId(com.google.android.fhir.datacapture.R.id.questionnaire_progress_indicator)) + .check { view, _ -> + val linearProgressIndicator = (view as LinearProgressIndicator) + assertThat(linearProgressIndicator.progress).isEqualTo(100) + } } @Test fun progressBar_shouldBeGone_whenNavigatedToReviewScreen() { buildFragmentFromQuestionnaire("/text_questionnaire_integer.json", isReviewMode = true) - onView(withId(R.id.review_mode_button)).perform(ViewActions.click()) + onView(withId(com.google.android.fhir.datacapture.R.id.review_mode_button)) + .perform(ViewActions.click()) - onView(withId(R.id.questionnaire_progress_indicator)).check { view, _ -> - val linearProgressIndicator = (view as LinearProgressIndicator) - assertThat(linearProgressIndicator.visibility).isEqualTo(View.GONE) - } + onView(withId(com.google.android.fhir.datacapture.R.id.questionnaire_progress_indicator)) + .check { view, _ -> + val linearProgressIndicator = (view as LinearProgressIndicator) + assertThat(linearProgressIndicator.visibility).isEqualTo(View.GONE) + } } @Test fun progressBar_shouldBeVisible_whenNavigatedToEditScreenFromReview() { buildFragmentFromQuestionnaire("/text_questionnaire_integer.json", isReviewMode = true) - onView(withId(R.id.review_mode_button)).perform(ViewActions.click()) + onView(withId(com.google.android.fhir.datacapture.R.id.review_mode_button)) + .perform(ViewActions.click()) - onView(withId(R.id.review_mode_edit_button)).perform(ViewActions.click()) + onView(withId(com.google.android.fhir.datacapture.R.id.review_mode_edit_button)) + .perform(ViewActions.click()) - onView(withId(R.id.questionnaire_progress_indicator)).check { view, _ -> - val linearProgressIndicator = (view as LinearProgressIndicator) - assertThat(linearProgressIndicator.visibility).isEqualTo(View.VISIBLE) - } + onView(withId(com.google.android.fhir.datacapture.R.id.questionnaire_progress_indicator)) + .check { view, _ -> + val linearProgressIndicator = (view as LinearProgressIndicator) + assertThat(linearProgressIndicator.visibility).isEqualTo(View.VISIBLE) + } } @Test fun test_repeated_group_is_added() { buildFragmentFromQuestionnaire("/component_repeated_group.json") + composeTestRule.onRoot().printToLog("ComposableHierarchy") + onView(withId(com.google.android.fhir.datacapture.R.id.add_item)).perform(ViewActions.click()) - onView(withId(R.id.questionnaire_edit_recycler_view)) - .perform( - RecyclerViewActions.actionOnItemAtPosition( - 0, - clickChildViewWithId(R.id.add_item), - ), - ) + composeTestRule + .onNodeWithTag(QuestionnaireFragment.QUESTIONNAIRE_EDIT_LIST) + .assertExists() + .assertIsDisplayed() - onView(ViewMatchers.withId(R.id.questionnaire_edit_recycler_view)).check { - view, - noViewFoundException, - -> - if (noViewFoundException != null) { - throw noViewFoundException - } - assertThat( - (view as RecyclerView).countChildViewOccurrences( - R.id.repeated_group_instance_header_title, - ), - ) - .isEqualTo(1) - } + onView(withId(com.google.android.fhir.datacapture.R.id.repeated_group_instance_header_title)) + .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) + + onView(withText(com.google.android.fhir.datacapture.R.string.delete)) + .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) } @Test @@ -645,51 +658,20 @@ class QuestionnaireUiEspressoTest { responseFileName = "/repeated_group_response.json", ) - onView(withId(R.id.questionnaire_edit_recycler_view)) - .perform( - RecyclerViewActions.actionOnItemAtPosition( - 1, - clickChildViewWithId(R.id.repeated_group_instance_header_delete_button), - ), - ) + composeTestRule + .onNodeWithTag(QuestionnaireFragment.QUESTIONNAIRE_EDIT_LIST) + .assertExists() + .assertIsDisplayed() - onView(ViewMatchers.withId(R.id.questionnaire_edit_recycler_view)).check { - view, - noViewFoundException, - -> - if (noViewFoundException != null) { - throw noViewFoundException - } - assertThat( - (view as RecyclerView).countChildViewOccurrences( - R.id.repeated_group_instance_header_title, - ), - ) - .isEqualTo(0) - } - } - - private fun RecyclerView.countChildViewOccurrences(viewId: Int): Int { - var count = 0 - for (i in 0 until this.adapter!!.itemCount) { - val holder = findViewHolderForAdapterPosition(i) - if (holder?.itemView?.findViewById(viewId) != null) { - count++ - } - } - return count - } - - private fun clickChildViewWithId(id: Int) = - object : ViewAction { - override fun getConstraints() = isAssignableFrom(View::class.java) + onView(withId(com.google.android.fhir.datacapture.R.id.repeated_group_instance_header_title)) + .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) - override fun getDescription() = "Click on a child view with specified id." + onView(withText(com.google.android.fhir.datacapture.R.string.delete)) + .perform(ViewActions.click()) - override fun perform(uiController: UiController?, view: View) { - view.findViewById(id)?.performClick() - } - } + onView(withText(com.google.android.fhir.datacapture.R.id.repeated_group_instance_header_title)) + .check(ViewAssertions.doesNotExist()) + } private fun buildFragmentFromQuestionnaire( fileName: String, @@ -706,7 +688,7 @@ class QuestionnaireUiEspressoTest { responseFileName?.let { builder.setQuestionnaireResponse(readFileFromAssets(it)) } return builder.build().also { fragment -> - activityScenarioRule.scenario.onActivity { activity -> + composeTestRule.activityRule.scenario.onActivity { activity -> activity.supportFragmentManager.commitNow { setReorderingAllowed(true) add(R.id.container_holder, fragment) @@ -724,7 +706,7 @@ class QuestionnaireUiEspressoTest { .setQuestionnaire(parser.encodeResourceToString(questionnaire)) .showReviewPageBeforeSubmit(isReviewMode) .build() - activityScenarioRule.scenario.onActivity { activity -> + composeTestRule.activityRule.scenario.onActivity { activity -> activity.supportFragmentManager.commitNow { setReorderingAllowed(true) add(R.id.container_holder, questionnaireFragment) @@ -737,7 +719,7 @@ class QuestionnaireUiEspressoTest { private suspend fun getQuestionnaireResponse(): QuestionnaireResponse { var testQuestionnaireFragment: QuestionnaireFragment? = null - activityScenarioRule.scenario.onActivity { activity -> + composeTestRule.activityRule.scenario.onActivity { activity -> testQuestionnaireFragment = activity.supportFragmentManager.findFragmentById(R.id.container_holder) as QuestionnaireFragment diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt index 6bf779e987..7610d9a48a 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt @@ -37,6 +37,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.testTag import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.res.use import androidx.core.os.bundleOf @@ -321,7 +322,7 @@ class QuestionnaireFragment : Fragment() { .collect { (visibleCount, total) -> onUpdateProgressIndicator(visibleCount, total) } } } - LazyColumn(state = listState) { + LazyColumn(state = listState, modifier = Modifier.testTag(QUESTIONNAIRE_EDIT_LIST)) { items( questionerStateFlow.value.items, key = { item -> @@ -648,6 +649,9 @@ class QuestionnaireFragment : Fragment() { */ internal const val EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON = "show-submit-anyway-button" + /** Test tag for QuestionnaireEditList */ + const val QUESTIONNAIRE_EDIT_LIST = "questionnaire_edit_list" + fun builder() = Builder() } From 5379f73f780dbc14adb718030b12c0160d105953 Mon Sep 17 00:00:00 2001 From: Elly Kitoto Date: Tue, 7 Oct 2025 11:08:11 +0300 Subject: [PATCH 06/12] Run spotlessApply Signed-off-by: Elly Kitoto --- .../fhir/datacapture/test/QuestionnaireUiEspressoTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/QuestionnaireUiEspressoTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/QuestionnaireUiEspressoTest.kt index dbe9a8b9ae..e2f04eab28 100644 --- a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/QuestionnaireUiEspressoTest.kt +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/QuestionnaireUiEspressoTest.kt @@ -162,7 +162,7 @@ class QuestionnaireUiEspressoTest { assertThat(getQuestionnaireResponse().item.first().answer.first().valueIntegerType.value) .isEqualTo(1) - onView(withId(com.google.android.fhir.datacapture.R.id.text_input_edit_text)).check { view, _ + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_edit_text)).check { view, _, -> assertThat((view as TextInputEditText).text.toString()).isEqualTo("001") } @@ -187,7 +187,7 @@ class QuestionnaireUiEspressoTest { assertThat(getQuestionnaireResponse().item.first().answer.first().valueDecimalType.value) .isEqualTo(BigDecimal.valueOf(0.01)) - onView(withId(com.google.android.fhir.datacapture.R.id.text_input_edit_text)).check { view, _ + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_edit_text)).check { view, _, -> assertThat((view as TextInputEditText).text.toString()).isEqualTo("0.01") } From 547071cacc003238ca467c4240cf75d85cb3551b Mon Sep 17 00:00:00 2001 From: Elly Kitoto Date: Mon, 13 Oct 2025 14:30:01 +0300 Subject: [PATCH 07/12] Add TODO to remove viewholder tagging View holder tagging on on composable views will no longer be necessary once all the views are migrated to compose. Signed-off-by: Elly Kitoto --- .../google/android/fhir/datacapture/QuestionnaireFragment.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt index 7610d9a48a..13c44a5c2b 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt @@ -343,6 +343,8 @@ class QuestionnaireFragment : Fragment() { // across recompositions, each created view is tagged with its viewHolder. // On recomposition, the views are not recreated—instead, their content is // refreshed by calling viewHolder#bind. + // TODO view holder tagging will not be necessary once the views are fully migrated + // compose refer to issue https://github.com/google/android-fhir/issues/2894 when (adapterItem) { is QuestionnaireAdapterItem.Question -> { val viewHolder = From 5838ceed8eabab15d878b0626b884b079292c9e0 Mon Sep 17 00:00:00 2001 From: Elly Kitoto Date: Mon, 13 Oct 2025 14:31:29 +0300 Subject: [PATCH 08/12] Fix rendering of nested repeated groups within repeated groups In order to prevent LazyColumn id name collisions when repeated groups are nested arbitrarily deep, the questionnaire ID is generated from the string derived from tracking all the accumulated paths from all ancestor repeated groups through recursive calls. Signed-off-by: Elly Kitoto --- .../datacapture/QuestionnaireViewModel.kt | 149 ++++++++++-------- 1 file changed, 84 insertions(+), 65 deletions(-) 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 4ff6af22f0..ea21344a25 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 @@ -862,10 +862,11 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat private suspend fun getQuestionnaireAdapterItems( questionnaireItemList: List, questionnaireResponseItemList: List, + parentIdPrefix: String = "", ): List { return questionnaireItemList .zipByLinkId(questionnaireResponseItemList) { questionnaireItem, questionnaireResponseItem -> - getQuestionnaireAdapterItems(questionnaireItem, questionnaireResponseItem) + getQuestionnaireAdapterItems(questionnaireItem, questionnaireResponseItem, parentIdPrefix) } .flatten() } @@ -877,6 +878,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat private suspend fun getQuestionnaireAdapterItems( questionnaireItem: QuestionnaireItemComponent, questionnaireResponseItem: QuestionnaireResponseItemComponent, + parentIdPrefix: String = "", ): List { // Hidden questions should not get QuestionnaireItemViewItem instances if (questionnaireItem.isHidden) return emptyList() @@ -937,49 +939,54 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat val question = QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - questionnaireResponseItem, - validationResult = validationResult, - answersChangedCallback = answersChangedCallback, - enabledAnswerOptions = enabledQuestionnaireAnswerOptions, - minAnswerValue = - questionnaireItem.minValueCqfCalculatedValueExpression?.let { - expressionEvaluator.evaluateExpressionValue( - questionnaireItem, - questionnaireResponseItem, - it, - ) - } - ?: questionnaireItem.minValue, - maxAnswerValue = - questionnaireItem.maxValueCqfCalculatedValueExpression?.let { - expressionEvaluator.evaluateExpressionValue( - questionnaireItem, - questionnaireResponseItem, - it, - ) - } - ?: questionnaireItem.maxValue, - draftAnswer = draftAnswerMap[questionnaireResponseItem], - enabledDisplayItems = - questionnaireItem.item.filter { - it.isDisplayItem && - enablementEvaluator.evaluate( + QuestionnaireViewItem( + questionnaireItem, + questionnaireResponseItem, + validationResult = validationResult, + answersChangedCallback = answersChangedCallback, + enabledAnswerOptions = enabledQuestionnaireAnswerOptions, + minAnswerValue = + questionnaireItem.minValueCqfCalculatedValueExpression?.let { + expressionEvaluator.evaluateExpressionValue( + questionnaireItem, + questionnaireResponseItem, it, + ) + } + ?: questionnaireItem.minValue, + maxAnswerValue = + questionnaireItem.maxValueCqfCalculatedValueExpression?.let { + expressionEvaluator.evaluateExpressionValue( + questionnaireItem, questionnaireResponseItem, + it, ) - }, - questionViewTextConfiguration = - QuestionTextConfiguration( - showAsterisk = showAsterisk, - showRequiredText = showRequiredText, - showOptionalText = showOptionalText, - ), - isHelpCardOpen = isHelpCard && isHelpCardOpen, - helpCardStateChangedCallback = helpCardStateChangedCallback, - ), - ) + } + ?: questionnaireItem.maxValue, + draftAnswer = draftAnswerMap[questionnaireResponseItem], + enabledDisplayItems = + questionnaireItem.item.filter { + it.isDisplayItem && + enablementEvaluator.evaluate( + it, + questionnaireResponseItem, + ) + }, + questionViewTextConfiguration = + QuestionTextConfiguration( + showAsterisk = showAsterisk, + showRequiredText = showRequiredText, + showOptionalText = showOptionalText, + ), + isHelpCardOpen = isHelpCard && isHelpCardOpen, + helpCardStateChangedCallback = helpCardStateChangedCallback, + ), + ) + .apply { + if (parentIdPrefix.isNotEmpty()) { + id = "${parentIdPrefix}${questionnaireItem.linkId}" + } + } add(question) // Add nested questions after the parent item. We need to get the questionnaire items and @@ -995,20 +1002,41 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat // view model, we create dummy answers for each repeated group. As a result the processing of // this case is similar to the case of questions nested under a question. // For background, see https://build.fhir.org/questionnaireresponse.html#link. - buildList { - // Case 1 - if (!questionnaireItem.isRepeatedGroup) { - add(questionnaireResponseItem.item) - } - // Case 2 and 3 - addAll(questionnaireResponseItem.answer.map { it.item }) - } + + // Case 1: Non-repeated group - process nested items directly with current prefix + if (!questionnaireItem.isRepeatedGroup && questionnaireResponseItem.item.isNotEmpty()) { + addAll( + getQuestionnaireAdapterItems( + questionnaireItemList = questionnaireItem.item.filterNot { it.isDisplayItem }, + questionnaireResponseItemList = questionnaireResponseItem.item, + parentIdPrefix = parentIdPrefix, + ), + ) + } + + // Case 2 and 3: Questions nested under answers (for questions with nested items or repeated + // groups) + questionnaireResponseItem.answer + .map { it.item } .forEachIndexed { index, nestedResponseItemList -> + val currentIdPrefix = + if (!questionnaireItem.isRepeatedGroup) { + // Case 2: Questions nested under a question (not a repeated group) + if (parentIdPrefix.isEmpty()) { + "${index}_${question.item.questionnaireItem.linkId}_" + } else { + "${parentIdPrefix}${index}_${question.item.questionnaireItem.linkId}_" + } + } else { + // Case 3: Build hierarchical ID prefix for nested repeated groups + "${parentIdPrefix}${index}_${question.item.questionnaireItem.linkId}_" + } + if (questionnaireItem.isRepeatedGroup) { // Case 3 add( QuestionnaireAdapterItem.RepeatedGroupHeader( - id = "${index}_${question.item.questionnaireItem.linkId}", + id = "${parentIdPrefix}${index}_${question.item.questionnaireItem.linkId}", index = index, onDeleteClicked = { viewModelScope.launch { question.item.removeAnswerAt(index) } }, responses = nestedResponseItemList, @@ -1018,21 +1046,12 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat } addAll( getQuestionnaireAdapterItems( - // If nested display item is identified as instructions or flyover, then do not - // create - // questionnaire state for it. - questionnaireItemList = questionnaireItem.item.filterNot { it.isDisplayItem }, - questionnaireResponseItemList = nestedResponseItemList, - ) - .onEach { - // Reset the question id to avoid duplicate keys in LazyColumn composable. The new - // id is derived from the the repeated group index, the parent question - // questionnaire item linkId and the linkId of the nested questions - if (it is QuestionnaireAdapterItem.Question) { - it.id = - "${index}_${question.item.questionnaireItem.linkId}_${it.item.questionnaireItem.linkId}" - } - }, + // If nested display item is identified as instructions or flyover, then do not + // create questionnaire state for it. + questionnaireItemList = questionnaireItem.item.filterNot { it.isDisplayItem }, + questionnaireResponseItemList = nestedResponseItemList, + parentIdPrefix = currentIdPrefix, + ), ) } } From 62ec99768628a2bfdbb3d65165e7329075cb0371 Mon Sep 17 00:00:00 2001 From: Elly Kitoto Date: Tue, 28 Oct 2025 02:42:47 +0300 Subject: [PATCH 09/12] Resolve unnessary recompositions in QuestionnaireEditList lazy column Commented code that invokes the handelInput function that triggers state change causing multiple recomposition. Added the onRest callback to the LazyColumn's AndroidView to reset the view before the update function is called during recomposition. Signed-off-by: Elly Kitoto --- .../test/QuestionnaireUiEspressoTest.kt | 76 ++---------- .../datacapture/QuestionnaireAdapterItem.kt | 1 + .../fhir/datacapture/QuestionnaireFragment.kt | 116 +++++++++++------- .../datacapture/QuestionnaireViewModel.kt | 7 +- .../factories/EditTextViewHolderFactory.kt | 6 +- 5 files changed, 97 insertions(+), 109 deletions(-) diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/QuestionnaireUiEspressoTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/QuestionnaireUiEspressoTest.kt index 49c525e2ee..7a14f5c31a 100644 --- a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/QuestionnaireUiEspressoTest.kt +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/QuestionnaireUiEspressoTest.kt @@ -30,7 +30,6 @@ import androidx.test.espresso.action.ViewActions import androidx.test.espresso.action.ViewActions.typeText import androidx.test.espresso.assertion.ViewAssertions import androidx.test.espresso.assertion.ViewAssertions.doesNotExist -import androidx.test.espresso.contrib.RecyclerViewActions import androidx.test.espresso.matcher.RootMatchers import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.withId @@ -60,6 +59,7 @@ import java.util.Calendar import java.util.Date import kotlinx.coroutines.test.runTest import org.hamcrest.CoreMatchers +import org.hamcrest.core.AllOf.allOf import org.hl7.fhir.r4.model.DateTimeType import org.hl7.fhir.r4.model.DateType import org.hl7.fhir.r4.model.Questionnaire @@ -638,19 +638,22 @@ class QuestionnaireUiEspressoTest { @Test fun test_add_item_button_does_not_exist_for_non_repeated_groups() { buildFragmentFromQuestionnaire("/component_non_repeated_group.json") - onView(withId(R.id.add_item_to_repeated_group)).check(doesNotExist()) + onView(withId(com.google.android.fhir.datacapture.R.id.add_item_to_repeated_group)) + .check(doesNotExist()) } @Test fun test_repeated_group_is_added() { buildFragmentFromQuestionnaire("/component_repeated_group.json") composeTestRule.onRoot().printToLog("ComposableHierarchy") - onView(withId(com.google.android.fhir.datacapture.R.id.add_item)).perform(ViewActions.click()) + onView(withId(com.google.android.fhir.datacapture.R.id.add_item_to_repeated_group)) + .perform(ViewActions.click()) composeTestRule .onNodeWithTag(QuestionnaireFragment.QUESTIONNAIRE_EDIT_LIST) .assertExists() .assertIsDisplayed() + onView(withId(com.google.android.fhir.datacapture.R.id.repeated_group_instance_header_title)) .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) @@ -661,70 +664,17 @@ class QuestionnaireUiEspressoTest { @Test fun test_repeated_group_adds_multiple_items() { buildFragmentFromQuestionnaire("/component_multiple_repeated_group.json") - onView(withId(R.id.questionnaire_edit_recycler_view)) - .perform( - RecyclerViewActions.actionOnItemAtPosition( - 1, // The add button position is 1 (zero-indexed) after the group's header - clickChildViewWithId(R.id.add_item_to_repeated_group), - ), - ) - .perform( - RecyclerViewActions.actionOnItemAtPosition( - 3, // The add button new position becomes 3 (zero-indexed) after the group's header, - // repeated item's header and the one item added - clickChildViewWithId(R.id.add_item_to_repeated_group), - ), - ) + onView(allOf(withText("Add Repeated Group"))).perform(ViewActions.click()) - onView(ViewMatchers.withId(R.id.questionnaire_edit_recycler_view)).check { - view, - noViewFoundException, - -> - if (noViewFoundException != null) { - throw noViewFoundException - } - assertThat( - (view as RecyclerView).countChildViewOccurrences( - R.id.repeated_group_instance_header_title, - ), - ) - .isEqualTo(2) - } - } + onView(allOf(withText(com.google.android.fhir.datacapture.R.string.delete))) + .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) - @Test - fun test_repeated_group_adds_items_for_subsequent() { - buildFragmentFromQuestionnaire("/component_multiple_repeated_group.json") - onView(withId(R.id.questionnaire_edit_recycler_view)) - .perform( - RecyclerViewActions.actionOnItemAtPosition( - 3, // The add button for the second repeated group is at position 3 (zero-indexed), after - // the first group's header (0), the first group's add button (1), and the second - // group's header (2) - clickChildViewWithId(R.id.add_item_to_repeated_group), + onView( + allOf( + withId(com.google.android.fhir.datacapture.R.id.repeated_group_instance_header_title), ), ) - .perform( - RecyclerViewActions.actionOnItemAtPosition( - 5, // The add button for the second group is now at position 5 after adding one item - clickChildViewWithId(R.id.add_item_to_repeated_group), - ), - ) - - onView(ViewMatchers.withId(R.id.questionnaire_edit_recycler_view)).check { - view, - noViewFoundException, - -> - if (noViewFoundException != null) { - throw noViewFoundException - } - assertThat( - (view as RecyclerView).countChildViewOccurrences( - R.id.repeated_group_instance_header_title, - ), - ) - .isEqualTo(2) - } + .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) } @Test diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireAdapterItem.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireAdapterItem.kt index 739a1e3e44..21e8e9817a 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireAdapterItem.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireAdapterItem.kt @@ -40,6 +40,7 @@ internal sealed interface QuestionnaireAdapterItem { ) : QuestionnaireAdapterItem data class RepeatedGroupAddButton( + var id: String?, val item: QuestionnaireViewItem, ) : QuestionnaireAdapterItem diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt index 22b1a7e3fe..4beb6cd666 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt @@ -31,9 +31,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.State -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.remember import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView @@ -49,9 +46,14 @@ import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_QUESTIONNAIRE_JSON_STRING +import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_QUESTIONNAIRE_JSON_URI +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.extensions.inflate import com.google.android.fhir.datacapture.validation.Invalid import com.google.android.fhir.datacapture.views.NavigationViewHolder +import com.google.android.fhir.datacapture.views.RepeatedGroupAddItemViewHolder import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolderFactory import com.google.android.fhir.datacapture.views.factories.RepeatedGroupHeaderItemViewHolder @@ -204,9 +206,9 @@ class QuestionnaireFragment : Fragment() { // Set items questionnaireReviewRecyclerView.visibility = View.GONE questionnaireEditComposeView.setContent { - val questionerStateFlow = viewModel.questionnaireStateFlow.collectAsState() QuestionnaireEditList( - questionerStateFlow, + items = state.items, + displayMode = displayMode, onUpdateProgressIndicator = { currentPage, totalCount -> questionnaireProgressIndicator.updateProgressIndicator( calculateProgressPercentage( @@ -296,16 +298,13 @@ class QuestionnaireFragment : Fragment() { @OptIn(ExperimentalUuidApi::class) @Composable private fun QuestionnaireEditList( - questionerStateFlow: State, + items: List, + displayMode: DisplayMode, onUpdateProgressIndicator: (Int, Int) -> Unit, ) { val listState = rememberLazyListState() - val currentDisplayMode = remember { questionerStateFlow.value.displayMode } - LaunchedEffect(listState) { - if ( - currentDisplayMode is DisplayMode.EditMode && !currentDisplayMode.pagination.isPaginated - ) { + if (displayMode is DisplayMode.EditMode && !displayMode.pagination.isPaginated) { snapshotFlow { val layoutInfo = listState.layoutInfo val visibleItems = layoutInfo.visibleItemsInfo @@ -324,13 +323,15 @@ class QuestionnaireFragment : Fragment() { } LazyColumn(state = listState, modifier = Modifier.testTag(QUESTIONNAIRE_EDIT_LIST)) { items( - questionerStateFlow.value.items, + items = items, key = { item -> when (item) { is QuestionnaireAdapterItem.Question -> item.id - ?: throw IllegalStateException("Missing id for the QuestionnaireAdapterItem: $item") + ?: throw IllegalStateException("Missing id for the Question: $item") is QuestionnaireAdapterItem.RepeatedGroupHeader -> item.id is QuestionnaireAdapterItem.Navigation -> "navigation" + is QuestionnaireAdapterItem.RepeatedGroupAddButton -> item.id + ?: throw IllegalStateException("Missing id for the RepeatedGroupAddButton: $item") } }, ) { adapterItem: QuestionnaireAdapterItem -> @@ -339,59 +340,88 @@ class QuestionnaireFragment : Fragment() { LinearLayout(context).apply { orientation = LinearLayout.VERTICAL ViewCompat.setNestedScrollingEnabled(this, false) - // Build the view using viewHolder factories. To keep the viewHolder accessible - // across recompositions, each created view is tagged with its viewHolder. - // On recomposition, the views are not recreated—instead, their content is - // refreshed by calling viewHolder#bind. - // TODO view holder tagging will not be necessary once the views are fully migrated - // compose refer to issue https://github.com/google/android-fhir/issues/2894 + } + }, + modifier = Modifier.fillMaxWidth(), + update = { view -> + val existingViewHolder = view.getTag(R.id.question_view_holder) + + val createViews = + when { + existingViewHolder == null -> true + adapterItem is QuestionnaireAdapterItem.Question && + existingViewHolder !is QuestionnaireItemViewHolder -> true + adapterItem is QuestionnaireAdapterItem.Navigation && + existingViewHolder !is NavigationViewHolder -> true + adapterItem is QuestionnaireAdapterItem.RepeatedGroupHeader && + existingViewHolder !is RepeatedGroupHeaderItemViewHolder -> true + adapterItem is QuestionnaireAdapterItem.RepeatedGroupAddButton && + existingViewHolder !is RepeatedGroupAddItemViewHolder -> true + else -> false + } + + if (createViews) { + view.removeAllViews() when (adapterItem) { is QuestionnaireAdapterItem.Question -> { val viewHolder = getQuestionnaireItemViewHolder( - parent = this, + parent = view, questionnaireViewItem = adapterItem.item, questionnaireItemViewHolderMatchers = questionnaireItemViewHolderFactoryMatchersProvider.get(), ) + view.setTag(R.id.question_view_holder, viewHolder) + view.addView(viewHolder.itemView) viewHolder.bind(adapterItem.item) - setTag(R.id.question_view_holder, viewHolder) - addView(viewHolder.itemView) } is QuestionnaireAdapterItem.Navigation -> { val viewHolder = - NavigationViewHolder(inflate(R.layout.pagination_navigation_view)) + NavigationViewHolder(view.inflate(R.layout.pagination_navigation_view)) + view.setTag(R.id.question_view_holder, viewHolder) + view.addView(viewHolder.itemView) viewHolder.bind(adapterItem.questionnaireNavigationUIState) - setTag(R.id.question_view_holder, viewHolder) - addView(viewHolder.itemView) } is QuestionnaireAdapterItem.RepeatedGroupHeader -> { val viewHolder = RepeatedGroupHeaderItemViewHolder( - inflate(R.layout.repeated_group_instance_header_view), + view.inflate(R.layout.repeated_group_instance_header_view), ) + view.setTag(R.id.question_view_holder, viewHolder) + view.addView(viewHolder.itemView) viewHolder.bind(adapterItem) - setTag(R.id.question_view_holder, viewHolder) - addView(viewHolder.itemView) + } + is QuestionnaireAdapterItem.RepeatedGroupAddButton -> { + val viewHolder = + RepeatedGroupAddItemViewHolder( + view.inflate(R.layout.add_repeated_item), + ) + view.setTag(R.id.question_view_holder, viewHolder) + view.addView(viewHolder.itemView) + viewHolder.bind(adapterItem.item) + } + } + } else { + // Update existing view holder + when (adapterItem) { + is QuestionnaireAdapterItem.Question -> { + (existingViewHolder as QuestionnaireItemViewHolder).bind(adapterItem.item) + } + is QuestionnaireAdapterItem.Navigation -> { + (existingViewHolder as NavigationViewHolder).bind( + adapterItem.questionnaireNavigationUIState, + ) + } + is QuestionnaireAdapterItem.RepeatedGroupHeader -> { + (existingViewHolder as RepeatedGroupHeaderItemViewHolder).bind(adapterItem) + } + is QuestionnaireAdapterItem.RepeatedGroupAddButton -> { + (existingViewHolder as RepeatedGroupAddItemViewHolder).bind(adapterItem.item) } } } }, - modifier = Modifier.fillMaxWidth(), - update = { view -> - val viewHolderTag = view.getTag(R.id.question_view_holder) - when (viewHolderTag) { - is QuestionnaireItemViewHolder -> - viewHolderTag.bind((adapterItem as QuestionnaireAdapterItem.Question).item) - is NavigationViewHolder -> - viewHolderTag.bind( - (adapterItem as QuestionnaireAdapterItem.Navigation) - .questionnaireNavigationUIState, - ) - is RepeatedGroupHeaderItemViewHolder -> - viewHolderTag.bind((adapterItem as QuestionnaireAdapterItem.RepeatedGroupHeader)) - } - }, + onReset = { view -> view.setTag(R.id.question_view_holder, null) }, ) } } 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 7a52ebf267..0161b32a8e 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 @@ -1056,7 +1056,12 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat } if (questionnaireItem.isRepeatedGroup) { - add(QuestionnaireAdapterItem.RepeatedGroupAddButton(question.item)) + add( + QuestionnaireAdapterItem.RepeatedGroupAddButton( + id = "${parentIdPrefix}${question.item.questionnaireItem.linkId}_add_btn", + item = question.item, + ), + ) } } currentPageItems = items diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/EditTextViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/EditTextViewHolderFactory.kt index caf3f7d222..991442daf2 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/EditTextViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/EditTextViewHolderFactory.kt @@ -82,11 +82,13 @@ abstract class QuestionnaireItemEditTextViewHolderDelegate(private val rawInputT as InputMethodManager) .hideSoftInputFromWindow(view.windowToken, 0) - context.lifecycleScope.launch { + // TODO ignore the code snippet below for now, it causes unnecessary recompositions in + // the lazy column used to render questionnaire fields + /*context.lifecycleScope.launch { // Update answer even if the text box loses focus without any change. This will mark the // questionnaire response item as being modified in the view model and trigger validation. handleInput(textInputEditText.editableText, questionnaireViewItem) - } + }*/ } } } From aca5f8845e0508d6319e780404fe3349138f4dc9 Mon Sep 17 00:00:00 2001 From: Elly Kitoto Date: Tue, 28 Oct 2025 11:30:15 +0300 Subject: [PATCH 10/12] Fix code formatting issues Signed-off-by: Elly Kitoto --- .../datacapture/test/QuestionnaireUiEspressoTest.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/QuestionnaireUiEspressoTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/QuestionnaireUiEspressoTest.kt index cae0fd3196..8b7c2e27db 100644 --- a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/QuestionnaireUiEspressoTest.kt +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/QuestionnaireUiEspressoTest.kt @@ -55,6 +55,11 @@ import com.google.android.material.progressindicator.LinearProgressIndicator import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputLayout import com.google.common.truth.Truth.assertThat +import java.math.BigDecimal +import java.time.LocalDate +import java.time.LocalDateTime +import java.util.Calendar +import java.util.Date import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import org.hamcrest.CoreMatchers @@ -68,11 +73,6 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import java.math.BigDecimal -import java.time.LocalDate -import java.time.LocalDateTime -import java.util.Calendar -import java.util.Date @RunWith(AndroidJUnit4::class) class QuestionnaireUiEspressoTest { From 14316148e4bb89a6370a1f1695348bfa9c57de46 Mon Sep 17 00:00:00 2001 From: Elly Kitoto Date: Tue, 28 Oct 2025 14:50:05 +0300 Subject: [PATCH 11/12] Run spotless Signed-off-by: Elly Kitoto --- .../google/android/fhir/datacapture/QuestionnaireFragment.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt index 0e0dde32e4..704fb3bf4c 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt @@ -57,10 +57,10 @@ import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemView import com.google.android.fhir.datacapture.views.factories.RepeatedGroupHeaderItemViewHolder import com.google.android.fhir.datacapture.views.factories.ReviewViewHolderFactory import com.google.android.material.progressindicator.LinearProgressIndicator +import kotlin.uuid.ExperimentalUuidApi import kotlinx.coroutines.launch import org.hl7.fhir.r4.model.Questionnaire import timber.log.Timber -import kotlin.uuid.ExperimentalUuidApi /** * A [Fragment] for displaying FHIR Questionnaires and getting user responses as FHIR @@ -164,7 +164,6 @@ class QuestionnaireFragment : Fragment() { setOnClickListener { viewModel.setReviewMode(false) } } - // Listen to updates from the view model. viewLifecycleOwner.lifecycleScope.launchWhenCreated { viewModel.questionnaireStateFlow.collect { state -> From f46265aca382a8918485c23dfdab4cc0c16d7e6d Mon Sep 17 00:00:00 2001 From: Elly Kitoto Date: Tue, 28 Oct 2025 15:08:35 +0300 Subject: [PATCH 12/12] Clean up code Signed-off-by: Elly Kitoto --- ...NumberViewHolderFactoryInstrumentedTest.kt | 21 - .../datacapture/QuestionnaireEditAdapter.kt | 442 --------- .../fhir/datacapture/QuestionnaireFragment.kt | 349 ++----- ...naireEditItem.kt => QuestionnaireLists.kt} | 208 +++- .../QuestionnaireViewHolderType.kt | 6 +- .../QuestionnaireEditAdapterTest.kt | 900 ------------------ 6 files changed, 287 insertions(+), 1639 deletions(-) delete mode 100644 datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditAdapter.kt rename datacapture/src/main/java/com/google/android/fhir/datacapture/{QuestionnaireEditItem.kt => QuestionnaireLists.kt} (51%) delete mode 100644 datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireEditAdapterTest.kt diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/contrib/views/PhoneNumberViewHolderFactoryInstrumentedTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/contrib/views/PhoneNumberViewHolderFactoryInstrumentedTest.kt index f0fcfcc94a..307a5738e3 100644 --- a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/contrib/views/PhoneNumberViewHolderFactoryInstrumentedTest.kt +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/contrib/views/PhoneNumberViewHolderFactoryInstrumentedTest.kt @@ -31,8 +31,6 @@ import androidx.compose.ui.test.performTextReplacement import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import com.google.android.fhir.datacapture.QuestionnaireEditAdapter -import com.google.android.fhir.datacapture.QuestionnaireViewHolderType import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.test.TestActivity import com.google.android.fhir.datacapture.validation.Invalid @@ -83,25 +81,6 @@ class PhoneNumberViewHolderFactoryInstrumentedTest { composeTestRule.unregisterIdlingResource(handlingTextIdlingResource) } - @Test - fun createViewHolder_phoneNumberViewHolderFactory_returnsViewHolder() { - val questionnaireEditAdapter = QuestionnaireEditAdapter() - val viewHolderFromAdapter = - questionnaireEditAdapter.createViewHolder( - parent, - QuestionnaireEditAdapter.ViewType.from( - type = QuestionnaireEditAdapter.ViewType.Type.QUESTION, - subtype = QuestionnaireViewHolderType.PHONE_NUMBER.value, - ) - .viewType, - ) as QuestionnaireEditAdapter.ViewHolder.QuestionHolder - - assertThat( - viewHolderFromAdapter.holder.itemView.visibility, - ) - .isEqualTo(View.VISIBLE) - } - @Test fun shouldSetTextViewText() { viewHolder.bind( 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 deleted file mode 100644 index d5243482c6..0000000000 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditAdapter.kt +++ /dev/null @@ -1,442 +0,0 @@ -/* - * 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. - * 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 - -import android.annotation.SuppressLint -import android.view.View -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -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.RepeatedGroupAddItemViewHolder -import com.google.android.fhir.datacapture.views.factories.AttachmentViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.AutoCompleteViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.BooleanChoiceViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.CheckBoxGroupViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.DatePickerViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.DateTimePickerViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.DisplayViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.DropDownViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.EditTextDecimalViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.EditTextIntegerViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.EditTextMultiLineViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.EditTextSingleLineViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.GroupViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.QuantityViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemDialogSelectViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder -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( - private val questionnaireItemViewHolderMatchers: - List = - emptyList(), -) : - ListAdapter(DiffCallbacks.ITEMS) { - /** - * @param viewType the integer value of the [QuestionnaireViewHolderType] used to render the - * [QuestionnaireViewItem]. - */ - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val typedViewType = ViewType.parse(viewType) - val subtype = typedViewType.subtype - return when (typedViewType.type) { - ViewType.Type.QUESTION -> - ViewHolder.QuestionHolder(onCreateViewHolderQuestion(parent = parent, subtype = subtype)) - ViewType.Type.REPEATED_GROUP_HEADER -> { - ViewHolder.RepeatedGroupHeaderHolder( - RepeatedGroupHeaderItemViewHolder( - parent.inflate(R.layout.repeated_group_instance_header_view), - ), - ) - } - ViewType.Type.NAVIGATION -> { - ViewHolder.NavigationHolder( - NavigationViewHolder( - parent.inflate(R.layout.pagination_navigation_view), - ), - ) - } - ViewType.Type.REPEATED_GROUP_ADD_BUTTON -> { - ViewHolder.RepeatedGroupAddButtonViewHolder( - RepeatedGroupAddItemViewHolder.create(parent), - ) - } - } - } - - private fun onCreateViewHolderQuestion( - parent: ViewGroup, - subtype: Int, - ): QuestionnaireItemViewHolder { - val numOfCanonicalWidgets = QuestionnaireViewHolderType.values().size - check(subtype < numOfCanonicalWidgets + questionnaireItemViewHolderMatchers.size) { - "Invalid widget type specified. Widget Int type cannot exceed the total number of supported custom and canonical widgets" - } - - // Map custom widget viewTypes to their corresponding widget factories - if (subtype >= numOfCanonicalWidgets) { - return questionnaireItemViewHolderMatchers[subtype - numOfCanonicalWidgets] - .factory - .create(parent) - } - - val viewHolderFactory = - when (QuestionnaireViewHolderType.fromInt(subtype)) { - 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 - QuestionnaireViewHolderType.EDIT_TEXT_INTEGER -> EditTextIntegerViewHolderFactory - QuestionnaireViewHolderType.EDIT_TEXT_DECIMAL -> EditTextDecimalViewHolderFactory - QuestionnaireViewHolderType.RADIO_GROUP -> RadioGroupViewHolderFactory - QuestionnaireViewHolderType.DROP_DOWN -> DropDownViewHolderFactory - QuestionnaireViewHolderType.DISPLAY -> DisplayViewHolderFactory - QuestionnaireViewHolderType.QUANTITY -> QuantityViewHolderFactory - QuestionnaireViewHolderType.CHECK_BOX_GROUP -> CheckBoxGroupViewHolderFactory - QuestionnaireViewHolderType.AUTO_COMPLETE -> AutoCompleteViewHolderFactory - QuestionnaireViewHolderType.DIALOG_SELECT -> QuestionnaireItemDialogSelectViewHolderFactory - QuestionnaireViewHolderType.SLIDER -> SliderViewHolderFactory - QuestionnaireViewHolderType.PHONE_NUMBER -> PhoneNumberViewHolderFactory - QuestionnaireViewHolderType.ATTACHMENT -> AttachmentViewHolderFactory - } - return viewHolderFactory.create(parent) - } - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - when (val item = getItem(position)) { - is QuestionnaireAdapterItem.Question -> { - holder as ViewHolder.QuestionHolder - holder.holder.bind(item.item) - } - is QuestionnaireAdapterItem.RepeatedGroupHeader -> { - holder as ViewHolder.RepeatedGroupHeaderHolder - holder.viewHolder.bind(item) - } - is QuestionnaireAdapterItem.Navigation -> { - holder as ViewHolder.NavigationHolder - holder.viewHolder.bind(item.questionnaireNavigationUIState) - } - is QuestionnaireAdapterItem.RepeatedGroupAddButton -> { - holder as ViewHolder.RepeatedGroupAddButtonViewHolder - holder.viewHolder.bind(item.item) - } - } - } - - override fun getItemViewType(position: Int): Int { - // Because we have multiple Item subtypes, we will pack two ints into the item view type. - - // The first 8 bits will be represented by this type, which is unique for each Item subclass. - val type: ViewType.Type - // The last 24 bits will be represented by this subtype, which will further divide each Item - // subclass into more view types. - val subtype: Int - when (val item = getItem(position)) { - is QuestionnaireAdapterItem.Question -> { - type = ViewType.Type.QUESTION - subtype = getItemViewTypeForQuestion(item.item) - } - is QuestionnaireAdapterItem.RepeatedGroupHeader -> { - type = ViewType.Type.REPEATED_GROUP_HEADER - // All of the repeated group headers will be rendered identically - subtype = 0 - } - is QuestionnaireAdapterItem.Navigation -> { - type = ViewType.Type.NAVIGATION - subtype = 0xFFFFFF - } - is QuestionnaireAdapterItem.RepeatedGroupAddButton -> { - type = ViewType.Type.REPEATED_GROUP_ADD_BUTTON - subtype = 0 - } - } - return ViewType.from(type = type, subtype = subtype).viewType - } - - /** - * Utility to pack two types (a "type" and "subtype") into a single "viewType" int, for use with - * [getItemViewType]. - * - * [type] is contained in the first 8 bits of the int, and should be unique for each type of - * [QuestionnaireAdapterItem]. - * - * [subtype] is contained in the lower 24 bits of the int, and should be used to differentiate - * between different items within the same [QuestionnaireAdapterItem] type. - */ - @JvmInline - internal value class ViewType(val viewType: Int) { - val subtype: Int - get() = viewType and 0xFFFFFF - - val type: Type - get() = Type.values()[viewType shr 24] - - companion object { - fun parse(viewType: Int): ViewType = ViewType(viewType) - - fun from(type: Type, subtype: Int): ViewType = ViewType((type.ordinal shl 24) or subtype) - } - - enum class Type { - QUESTION, - REPEATED_GROUP_HEADER, - REPEATED_GROUP_ADD_BUTTON, - NAVIGATION, - } - } - - /** - * Returns the integer value of the [QuestionnaireViewHolderType] that will be used to render the - * [QuestionnaireViewItem]. This is determined by a combination of the data type of the question - * and any additional Questionnaire Item UI Control Codes - * (http://hl7.org/fhir/R4/valueset-questionnaire-item-control.html) used in the itemControl - * extension (http://hl7.org/fhir/R4/extension-questionnaire-itemcontrol.html). - */ - internal fun getItemViewTypeForQuestion( - questionnaireViewItem: QuestionnaireViewItem, - ): Int { - val questionnaireItem = questionnaireViewItem.questionnaireItem - // For custom widgets, generate an int value that's greater than any int assigned to the - // canonical FHIR widgets - questionnaireItemViewHolderMatchers.forEachIndexed { index, matcher -> - if (matcher.matches(questionnaireItem)) { - return index + QuestionnaireViewHolderType.values().size - } - } - - if (questionnaireViewItem.enabledAnswerOptions.isNotEmpty()) { - return getChoiceViewHolderType(questionnaireViewItem).value - } - - return when (val type = questionnaireItem.type) { - 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 - QuestionnaireItemType.INTEGER -> getIntegerViewHolderType(questionnaireViewItem) - QuestionnaireItemType.DECIMAL -> QuestionnaireViewHolderType.EDIT_TEXT_DECIMAL - QuestionnaireItemType.CHOICE -> getChoiceViewHolderType(questionnaireViewItem) - QuestionnaireItemType.DISPLAY -> QuestionnaireViewHolderType.DISPLAY - QuestionnaireItemType.QUANTITY -> QuestionnaireViewHolderType.QUANTITY - QuestionnaireItemType.REFERENCE -> getChoiceViewHolderType(questionnaireViewItem) - QuestionnaireItemType.ATTACHMENT -> QuestionnaireViewHolderType.ATTACHMENT - else -> throw NotImplementedError("Question type $type not supported.") - }.value - } - - private fun getChoiceViewHolderType( - questionnaireViewItem: QuestionnaireViewItem, - ): QuestionnaireViewHolderType { - val questionnaireItem = questionnaireViewItem.questionnaireItem - - // 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 - when { - // Always use a dialog for questions with a large number of options - numOptions >= MINIMUM_NUMBER_OF_ANSWER_OPTIONS_FOR_DIALOG -> - QuestionnaireViewHolderType.DIALOG_SELECT - - // Use a check box group if repeated answers are permitted - questionnaireItem.repeats -> QuestionnaireViewHolderType.CHECK_BOX_GROUP - - // Use a dropdown if there are a medium number of options - numOptions >= MINIMUM_NUMBER_OF_ANSWER_OPTIONS_FOR_DROP_DOWN -> - QuestionnaireViewHolderType.DROP_DOWN - - // Use a radio group only if there are a small number of options - else -> QuestionnaireViewHolderType.RADIO_GROUP - } - } - } - - private fun getIntegerViewHolderType( - questionnaireViewItem: QuestionnaireViewItem, - ): QuestionnaireViewHolderType { - val questionnaireItem = questionnaireViewItem.questionnaireItem - // Use the view type that the client wants if they specified an itemControl - return questionnaireItem.itemControl?.viewHolderType - ?: QuestionnaireViewHolderType.EDIT_TEXT_INTEGER - } - - private fun getStringViewHolderType( - questionnaireViewItem: QuestionnaireViewItem, - ): QuestionnaireViewHolderType { - val questionnaireItem = questionnaireViewItem.questionnaireItem - // Use the view type that the client wants if they specified an itemControl - return questionnaireItem.itemControl?.viewHolderType - ?: QuestionnaireViewHolderType.EDIT_TEXT_SINGLE_LINE - } - - internal sealed class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - class QuestionHolder(val holder: QuestionnaireItemViewHolder) : ViewHolder(holder.itemView) - - class RepeatedGroupHeaderHolder(val viewHolder: RepeatedGroupHeaderItemViewHolder) : - ViewHolder(viewHolder.itemView) - - class NavigationHolder(val viewHolder: NavigationViewHolder) : ViewHolder(viewHolder.itemView) - - class RepeatedGroupAddButtonViewHolder(val viewHolder: RepeatedGroupAddItemViewHolder) : - ViewHolder(viewHolder.itemView) - } - - internal companion object { - // Choice questions are rendered as dialogs if they have at least this many options - const val MINIMUM_NUMBER_OF_ANSWER_OPTIONS_FOR_DIALOG = 10 - - // Choice questions are rendered as radio group if number of options less than this constant - const val MINIMUM_NUMBER_OF_ANSWER_OPTIONS_FOR_DROP_DOWN = 4 - } -} - -internal object DiffCallbacks { - val ITEMS = - object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: QuestionnaireAdapterItem, - newItem: QuestionnaireAdapterItem, - ): Boolean = - when (oldItem) { - is QuestionnaireAdapterItem.Question -> { - newItem is QuestionnaireAdapterItem.Question && - QUESTIONS.areItemsTheSame(oldItem, newItem) - } - is QuestionnaireAdapterItem.RepeatedGroupHeader -> { - newItem is QuestionnaireAdapterItem.RepeatedGroupHeader && - oldItem.index == newItem.index - } - is QuestionnaireAdapterItem.Navigation -> newItem is QuestionnaireAdapterItem.Navigation - is QuestionnaireAdapterItem.RepeatedGroupAddButton -> { - newItem is QuestionnaireAdapterItem.RepeatedGroupAddButton && - oldItem.item.hasTheSameItem(newItem.item) - } - } - - override fun areContentsTheSame( - oldItem: QuestionnaireAdapterItem, - newItem: QuestionnaireAdapterItem, - ): Boolean = - when (oldItem) { - is QuestionnaireAdapterItem.Question -> { - newItem is QuestionnaireAdapterItem.Question && - QUESTIONS.areContentsTheSame(oldItem, newItem) - } - is QuestionnaireAdapterItem.RepeatedGroupHeader -> { - if (newItem is QuestionnaireAdapterItem.RepeatedGroupHeader) { - // The `onDeleteClicked` function is a function closure generated in the questionnaire - // viewmodel with a reference to the parent questionnaire view item. When it is - // invoked, it deletes the current repeated group instance from the parent - // questionnaire view item by removing it from the list of children in the parent - // questionnaire view. - // In other words, although the `onDeleteClicked` function is not a data field, it is - // a function closure with references to data structures. Because - // `RepeatedGroupHeader` does not include any other data fields besides the index, it - // is particularly important to distinguish between different `RepeatedGroupHeader`s - // by the `onDeleteClicked` function. - // If this check is not here, an old RepeatedGroupHeader might be mistakenly - // considered up-to-date and retained in the recycler view even though a newer - // version includes a different `onDeleteClicked` function referencing a parent item - // with a different list of children. As a result clicking the delete function might - // result in deleting from an old list. - @SuppressLint("DiffUtilEquals") - val onDeleteClickedCallbacksEqual = oldItem.onDeleteClicked == newItem.onDeleteClicked - onDeleteClickedCallbacksEqual - } else { - false - } - } - is QuestionnaireAdapterItem.Navigation -> { - newItem is QuestionnaireAdapterItem.Navigation && - oldItem.questionnaireNavigationUIState == newItem.questionnaireNavigationUIState - } - is QuestionnaireAdapterItem.RepeatedGroupAddButton -> { - newItem is QuestionnaireAdapterItem.RepeatedGroupAddButton && - oldItem.item.hasTheSameItem(newItem.item) && - oldItem.item.hasTheSameResponse(newItem.item) && - oldItem.item.hasTheSameValidationResult(newItem.item) - } - } - } - - val QUESTIONS = - object : DiffUtil.ItemCallback() { - /** - * [QuestionnaireViewItem] is a transient object for the UI only. Whenever the user makes any - * change via the UI, a new list of [QuestionnaireViewItem]s will be created, each holding - * references to the underlying [QuestionnaireItem] and [QuestionnaireResponseItem], both of - * which should be read-only, and the current answers. To help recycler view handle update - * and/or animations, we consider two [QuestionnaireViewItem]s to be the same if they have the - * same underlying [QuestionnaireItem] and [QuestionnaireResponseItem]. - */ - override fun areItemsTheSame( - oldItem: QuestionnaireAdapterItem.Question, - newItem: QuestionnaireAdapterItem.Question, - ) = oldItem.item.hasTheSameItem(newItem.item) - - override fun areContentsTheSame( - oldItem: QuestionnaireAdapterItem.Question, - newItem: QuestionnaireAdapterItem.Question, - ): Boolean { - return oldItem.item.hasTheSameItem(newItem.item) && - oldItem.item.hasTheSameResponse(newItem.item) && - oldItem.item.hasTheSameValidationResult(newItem.item) - } - } - - val REVIEW_ITEMS = - object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: ReviewAdapterItem, - newItem: ReviewAdapterItem, - ): Boolean = - ITEMS.areItemsTheSame( - oldItem as QuestionnaireAdapterItem, - newItem as QuestionnaireAdapterItem, - ) - - override fun areContentsTheSame( - oldItem: ReviewAdapterItem, - newItem: ReviewAdapterItem, - ): Boolean = - ITEMS.areContentsTheSame( - oldItem as QuestionnaireAdapterItem, - newItem as QuestionnaireAdapterItem, - ) - } -} diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt index 704fb3bf4c..e94e5359eb 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt @@ -21,43 +21,27 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.LinearLayout import android.widget.TextView import androidx.annotation.VisibleForTesting import androidx.appcompat.view.ContextThemeWrapper -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.res.use import androidx.core.os.bundleOf -import androidx.core.view.ViewCompat import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.setFragmentResult import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_QUESTIONNAIRE_JSON_STRING import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_QUESTIONNAIRE_JSON_URI 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.extensions.inflate import com.google.android.fhir.datacapture.validation.Invalid import com.google.android.fhir.datacapture.views.NavigationViewHolder -import com.google.android.fhir.datacapture.views.RepeatedGroupAddItemViewHolder -import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.RepeatedGroupHeaderItemViewHolder -import com.google.android.fhir.datacapture.views.factories.ReviewViewHolderFactory import com.google.android.material.progressindicator.LinearProgressIndicator -import kotlin.uuid.ExperimentalUuidApi import kotlinx.coroutines.launch import org.hl7.fhir.r4.model.Questionnaire import timber.log.Timber @@ -165,86 +149,90 @@ class QuestionnaireFragment : Fragment() { } // Listen to updates from the view model. - viewLifecycleOwner.lifecycleScope.launchWhenCreated { - viewModel.questionnaireStateFlow.collect { state -> - when (val displayMode = state.displayMode) { - is DisplayMode.ReviewMode -> { - // Set items - - questionnaireReviewComposeView.visibility = View.VISIBLE - questionnaireReviewComposeView.setContent { QuestionnaireReviewList(state.items) } - questionnaireEditComposeView.visibility = View.GONE - - reviewModeEditButton.visibility = - if (displayMode.showEditButton) { - View.VISIBLE + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) { + viewModel.questionnaireStateFlow.collect { state -> + when (val displayMode = state.displayMode) { + is DisplayMode.ReviewMode -> { + // Set items + + questionnaireReviewComposeView.visibility = View.VISIBLE + questionnaireReviewComposeView.setContent { QuestionnaireReviewList(state.items) } + questionnaireEditComposeView.visibility = View.GONE + + reviewModeEditButton.visibility = + if (displayMode.showEditButton) { + View.VISIBLE + } else { + View.GONE + } + questionnaireTitle.visibility = View.VISIBLE + questionnaireTitle.text = getString(R.string.questionnaire_review_mode_title) + + // Set bottom navigation + if (state.bottomNavItem != null) { + bottomNavContainerFrame.visibility = View.VISIBLE + NavigationViewHolder(bottomNavContainerFrame) + .bind(state.bottomNavItem.questionnaireNavigationUIState) } else { - View.GONE + bottomNavContainerFrame.visibility = View.GONE } - questionnaireTitle.visibility = View.VISIBLE - questionnaireTitle.text = getString(R.string.questionnaire_review_mode_title) - - // Set bottom navigation - if (state.bottomNavItem != null) { - bottomNavContainerFrame.visibility = View.VISIBLE - NavigationViewHolder(bottomNavContainerFrame) - .bind(state.bottomNavItem.questionnaireNavigationUIState) - } else { - bottomNavContainerFrame.visibility = View.GONE + + // Hide progress indicator + questionnaireProgressIndicator.visibility = View.GONE } + is DisplayMode.EditMode -> { + // Set items + questionnaireReviewComposeView.visibility = View.GONE + questionnaireEditComposeView.setContent { + QuestionnaireEditList( + items = state.items, + displayMode = displayMode, + questionnaireItemViewHolderMatchers = + questionnaireItemViewHolderFactoryMatchersProvider.get(), + onUpdateProgressIndicator = { currentPage, totalCount -> + questionnaireProgressIndicator.updateProgressIndicator( + calculateProgressPercentage( + count = (currentPage + 1), + totalCount = totalCount, + ), + ) + }, + ) + } + questionnaireEditComposeView.visibility = View.VISIBLE + reviewModeEditButton.visibility = View.GONE + questionnaireTitle.visibility = View.GONE + + // Set bottom navigation + if (state.bottomNavItem != null) { + bottomNavContainerFrame.visibility = View.VISIBLE + NavigationViewHolder(bottomNavContainerFrame) + .bind(state.bottomNavItem.questionnaireNavigationUIState) + } else { + bottomNavContainerFrame.visibility = View.GONE + } - // Hide progress indicator - questionnaireProgressIndicator.visibility = View.GONE - } - is DisplayMode.EditMode -> { - // Set items - questionnaireReviewComposeView.visibility = View.GONE - questionnaireEditComposeView.setContent { - QuestionnaireEditList( - items = state.items, - displayMode = displayMode, - onUpdateProgressIndicator = { currentPage, totalCount -> - questionnaireProgressIndicator.updateProgressIndicator( - calculateProgressPercentage( - count = (currentPage + 1), - totalCount = totalCount, - ), - ) - }, - ) + // Set progress indicator + questionnaireProgressIndicator.visibility = View.VISIBLE + if (displayMode.pagination.isPaginated) { + questionnaireProgressIndicator.updateProgressIndicator( + calculateProgressPercentage( + count = + (displayMode.pagination.currentPageIndex + + 1), // incremented by 1 due to initialPageIndex starts with 0. + totalCount = displayMode.pagination.pages.size, + ), + ) + } } - questionnaireEditComposeView.visibility = View.VISIBLE - reviewModeEditButton.visibility = View.GONE - questionnaireTitle.visibility = View.GONE - - // Set bottom navigation - if (state.bottomNavItem != null) { - bottomNavContainerFrame.visibility = View.VISIBLE - NavigationViewHolder(bottomNavContainerFrame) - .bind(state.bottomNavItem.questionnaireNavigationUIState) - } else { + is DisplayMode.InitMode -> { + questionnaireReviewComposeView.visibility = View.GONE + questionnaireEditComposeView.visibility = View.GONE + questionnaireProgressIndicator.visibility = View.GONE + reviewModeEditButton.visibility = View.GONE bottomNavContainerFrame.visibility = View.GONE } - - // Set progress indicator - questionnaireProgressIndicator.visibility = View.VISIBLE - if (displayMode.pagination.isPaginated) { - questionnaireProgressIndicator.updateProgressIndicator( - calculateProgressPercentage( - count = - (displayMode.pagination.currentPageIndex + - 1), // incremented by 1 due to initialPageIndex starts with 0. - totalCount = displayMode.pagination.pages.size, - ), - ) - } - } - is DisplayMode.InitMode -> { - questionnaireReviewComposeView.visibility = View.GONE - questionnaireEditComposeView.visibility = View.GONE - questionnaireProgressIndicator.visibility = View.GONE - reviewModeEditButton.visibility = View.GONE - bottomNavContainerFrame.visibility = View.GONE } } } @@ -289,185 +277,6 @@ class QuestionnaireFragment : Fragment() { } } - @OptIn(ExperimentalUuidApi::class) - @Composable - private fun QuestionnaireEditList( - items: List, - displayMode: DisplayMode, - onUpdateProgressIndicator: (Int, Int) -> Unit, - ) { - val listState = rememberLazyListState() - LaunchedEffect(listState) { - if (displayMode is DisplayMode.EditMode && !displayMode.pagination.isPaginated) { - snapshotFlow { - val layoutInfo = listState.layoutInfo - val visibleItems = layoutInfo.visibleItemsInfo - val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 - val total = layoutInfo.totalItemsCount - - // If all items are visible, we're at 100% - if (visibleItems.size >= total && total > 0) { - total to total - } else { - lastVisible + 1 to total - } - } - .collect { (visibleCount, total) -> onUpdateProgressIndicator(visibleCount, total) } - } - } - LazyColumn(state = listState, modifier = Modifier.testTag(QUESTIONNAIRE_EDIT_LIST)) { - items( - items = items, - key = { item -> - when (item) { - is QuestionnaireAdapterItem.Question -> item.id - ?: throw IllegalStateException("Missing id for the Question: $item") - is QuestionnaireAdapterItem.RepeatedGroupHeader -> item.id - is QuestionnaireAdapterItem.Navigation -> "navigation" - is QuestionnaireAdapterItem.RepeatedGroupAddButton -> item.id - ?: throw IllegalStateException("Missing id for the RepeatedGroupAddButton: $item") - } - }, - ) { adapterItem: QuestionnaireAdapterItem -> - AndroidView( - factory = { context -> - LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - ViewCompat.setNestedScrollingEnabled(this, false) - } - }, - modifier = Modifier.fillMaxWidth(), - update = { view -> - val existingViewHolder = view.getTag(R.id.question_view_holder) - - val createViews = - when { - existingViewHolder == null -> true - adapterItem is QuestionnaireAdapterItem.Question && - existingViewHolder !is QuestionnaireItemViewHolder -> true - adapterItem is QuestionnaireAdapterItem.Navigation && - existingViewHolder !is NavigationViewHolder -> true - adapterItem is QuestionnaireAdapterItem.RepeatedGroupHeader && - existingViewHolder !is RepeatedGroupHeaderItemViewHolder -> true - adapterItem is QuestionnaireAdapterItem.RepeatedGroupAddButton && - existingViewHolder !is RepeatedGroupAddItemViewHolder -> true - else -> false - } - - if (createViews) { - view.removeAllViews() - when (adapterItem) { - is QuestionnaireAdapterItem.Question -> { - val viewHolder = - getQuestionnaireItemViewHolder( - parent = view, - questionnaireViewItem = adapterItem.item, - questionnaireItemViewHolderMatchers = - questionnaireItemViewHolderFactoryMatchersProvider.get(), - ) - view.setTag(R.id.question_view_holder, viewHolder) - view.addView(viewHolder.itemView) - viewHolder.bind(adapterItem.item) - } - is QuestionnaireAdapterItem.Navigation -> { - val viewHolder = - NavigationViewHolder(view.inflate(R.layout.pagination_navigation_view)) - view.setTag(R.id.question_view_holder, viewHolder) - view.addView(viewHolder.itemView) - viewHolder.bind(adapterItem.questionnaireNavigationUIState) - } - is QuestionnaireAdapterItem.RepeatedGroupHeader -> { - val viewHolder = - RepeatedGroupHeaderItemViewHolder( - view.inflate(R.layout.repeated_group_instance_header_view), - ) - view.setTag(R.id.question_view_holder, viewHolder) - view.addView(viewHolder.itemView) - viewHolder.bind(adapterItem) - } - is QuestionnaireAdapterItem.RepeatedGroupAddButton -> { - val viewHolder = - RepeatedGroupAddItemViewHolder( - view.inflate(R.layout.add_repeated_item), - ) - view.setTag(R.id.question_view_holder, viewHolder) - view.addView(viewHolder.itemView) - viewHolder.bind(adapterItem.item) - } - } - } else { - // Update existing view holder - when (adapterItem) { - is QuestionnaireAdapterItem.Question -> { - (existingViewHolder as QuestionnaireItemViewHolder).bind(adapterItem.item) - } - is QuestionnaireAdapterItem.Navigation -> { - (existingViewHolder as NavigationViewHolder).bind( - adapterItem.questionnaireNavigationUIState, - ) - } - is QuestionnaireAdapterItem.RepeatedGroupHeader -> { - (existingViewHolder as RepeatedGroupHeaderItemViewHolder).bind(adapterItem) - } - is QuestionnaireAdapterItem.RepeatedGroupAddButton -> { - (existingViewHolder as RepeatedGroupAddItemViewHolder).bind(adapterItem.item) - } - } - } - }, - onReset = { view -> view.setTag(R.id.question_view_holder, null) }, - ) - } - } - } - - @Composable - private fun QuestionnaireReviewList(items: List) { - LazyColumn { - items( - items = items, - key = { item -> - when (item) { - is QuestionnaireAdapterItem.Question -> item.id - ?: throw IllegalStateException("Missing id for the Question: $item") - is QuestionnaireAdapterItem.RepeatedGroupHeader -> item.id - is QuestionnaireAdapterItem.Navigation -> "navigation" - is QuestionnaireAdapterItem.RepeatedGroupAddButton -> item.id - ?: throw IllegalStateException("Missing id for the RepeatedGroupAddButton: $item") - } - }, - ) { item: QuestionnaireAdapterItem -> - AndroidView( - factory = { context -> - LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - when (item) { - is QuestionnaireAdapterItem.Question -> { - val viewHolder = ReviewViewHolderFactory.create(this) - viewHolder.bind(item.item) - addView(viewHolder.itemView) - } - is QuestionnaireAdapterItem.Navigation -> { - val viewHolder = - NavigationViewHolder(inflate(R.layout.pagination_navigation_view)) - viewHolder.bind(item.questionnaireNavigationUIState) - addView(viewHolder.itemView) - } - is QuestionnaireAdapterItem.RepeatedGroupHeader -> { - TODO("Not implemented yet") - } - is QuestionnaireAdapterItem.RepeatedGroupAddButton -> { - TODO("Not implemented yet") - } - } - } - }, - modifier = Modifier.fillMaxWidth(), - ) - } - } - } - /** Calculates the progress percentage from given [count] and [totalCount] values. */ internal fun calculateProgressPercentage(count: Int, totalCount: Int): Int { return if (totalCount == 0) 0 else (count * 100 / totalCount) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditItem.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireLists.kt similarity index 51% rename from datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditItem.kt rename to datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireLists.kt index 682c5c5527..544aef30fc 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditItem.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireLists.kt @@ -17,12 +17,26 @@ package com.google.android.fhir.datacapture import android.view.ViewGroup -import com.google.android.fhir.datacapture.QuestionnaireEditAdapter.Companion.MINIMUM_NUMBER_OF_ANSWER_OPTIONS_FOR_DIALOG -import com.google.android.fhir.datacapture.QuestionnaireEditAdapter.Companion.MINIMUM_NUMBER_OF_ANSWER_OPTIONS_FOR_DROP_DOWN +import android.widget.LinearLayout +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.ViewCompat +import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.QUESTIONNAIRE_EDIT_LIST 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.RepeatedGroupAddItemViewHolder import com.google.android.fhir.datacapture.views.factories.AttachmentViewHolderFactory import com.google.android.fhir.datacapture.views.factories.AutoCompleteViewHolderFactory import com.google.android.fhir.datacapture.views.factories.BooleanChoiceViewHolderFactory @@ -41,11 +55,199 @@ import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemDial import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolderFactory 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.ReviewViewHolderFactory import com.google.android.fhir.datacapture.views.factories.SliderViewHolderFactory import com.google.android.fhir.datacapture.views.factories.TimePickerViewHolderFactory +import kotlin.uuid.ExperimentalUuidApi import org.hl7.fhir.r4.model.Questionnaire -fun getQuestionnaireItemViewHolder( +// Choice questions are rendered as dialogs if they have at least this many options +const val MINIMUM_NUMBER_OF_ANSWER_OPTIONS_FOR_DIALOG = 10 + +// Choice questions are rendered as radio group if number of options less than this constant +const val MINIMUM_NUMBER_OF_ANSWER_OPTIONS_FOR_DROP_DOWN = 4 + +@OptIn(ExperimentalUuidApi::class) +@Composable +internal fun QuestionnaireEditList( + items: List, + displayMode: DisplayMode, + questionnaireItemViewHolderMatchers: + List, + onUpdateProgressIndicator: (Int, Int) -> Unit, +) { + val listState = rememberLazyListState() + LaunchedEffect(listState) { + if (displayMode is DisplayMode.EditMode && !displayMode.pagination.isPaginated) { + snapshotFlow { + val layoutInfo = listState.layoutInfo + val visibleItems = layoutInfo.visibleItemsInfo + val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + val total = layoutInfo.totalItemsCount + + // If all items are visible, we're at 100% + if (visibleItems.size >= total && total > 0) { + total to total + } else { + lastVisible + 1 to total + } + } + .collect { (visibleCount, total) -> onUpdateProgressIndicator(visibleCount, total) } + } + } + LazyColumn(state = listState, modifier = Modifier.testTag(QUESTIONNAIRE_EDIT_LIST)) { + items( + items = items, + key = { item -> + when (item) { + is QuestionnaireAdapterItem.Question -> item.id + ?: throw IllegalStateException("Missing id for the Question: $item") + is QuestionnaireAdapterItem.RepeatedGroupHeader -> item.id + is QuestionnaireAdapterItem.Navigation -> "navigation" + is QuestionnaireAdapterItem.RepeatedGroupAddButton -> item.id + ?: throw IllegalStateException("Missing id for the RepeatedGroupAddButton: $item") + } + }, + ) { adapterItem: QuestionnaireAdapterItem -> + AndroidView( + factory = { context -> + LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + ViewCompat.setNestedScrollingEnabled(this, false) + } + }, + modifier = Modifier.fillMaxWidth(), + update = { view -> + val existingViewHolder = view.getTag(R.id.question_view_holder) + + val createViews = + when { + existingViewHolder == null -> true + adapterItem is QuestionnaireAdapterItem.Question && + existingViewHolder !is QuestionnaireItemViewHolder -> true + adapterItem is QuestionnaireAdapterItem.Navigation && + existingViewHolder !is NavigationViewHolder -> true + adapterItem is QuestionnaireAdapterItem.RepeatedGroupHeader && + existingViewHolder !is RepeatedGroupHeaderItemViewHolder -> true + adapterItem is QuestionnaireAdapterItem.RepeatedGroupAddButton && + existingViewHolder !is RepeatedGroupAddItemViewHolder -> true + else -> false + } + + if (createViews) { + view.removeAllViews() + when (adapterItem) { + is QuestionnaireAdapterItem.Question -> { + val viewHolder = + getQuestionnaireItemViewHolder( + parent = view, + questionnaireViewItem = adapterItem.item, + questionnaireItemViewHolderMatchers = questionnaireItemViewHolderMatchers, + ) + view.setTag(R.id.question_view_holder, viewHolder) + view.addView(viewHolder.itemView) + viewHolder.bind(adapterItem.item) + } + is QuestionnaireAdapterItem.Navigation -> { + val viewHolder = + NavigationViewHolder(view.inflate(R.layout.pagination_navigation_view)) + view.setTag(R.id.question_view_holder, viewHolder) + view.addView(viewHolder.itemView) + viewHolder.bind(adapterItem.questionnaireNavigationUIState) + } + is QuestionnaireAdapterItem.RepeatedGroupHeader -> { + val viewHolder = + RepeatedGroupHeaderItemViewHolder( + view.inflate(R.layout.repeated_group_instance_header_view), + ) + view.setTag(R.id.question_view_holder, viewHolder) + view.addView(viewHolder.itemView) + viewHolder.bind(adapterItem) + } + is QuestionnaireAdapterItem.RepeatedGroupAddButton -> { + val viewHolder = + RepeatedGroupAddItemViewHolder( + view.inflate(R.layout.add_repeated_item), + ) + view.setTag(R.id.question_view_holder, viewHolder) + view.addView(viewHolder.itemView) + viewHolder.bind(adapterItem.item) + } + } + } else { + // Update existing view holder + when (adapterItem) { + is QuestionnaireAdapterItem.Question -> { + (existingViewHolder as QuestionnaireItemViewHolder).bind(adapterItem.item) + } + is QuestionnaireAdapterItem.Navigation -> { + (existingViewHolder as NavigationViewHolder).bind( + adapterItem.questionnaireNavigationUIState, + ) + } + is QuestionnaireAdapterItem.RepeatedGroupHeader -> { + (existingViewHolder as RepeatedGroupHeaderItemViewHolder).bind(adapterItem) + } + is QuestionnaireAdapterItem.RepeatedGroupAddButton -> { + (existingViewHolder as RepeatedGroupAddItemViewHolder).bind(adapterItem.item) + } + } + } + }, + onReset = { view -> view.setTag(R.id.question_view_holder, null) }, + ) + } + } +} + +@Composable +internal fun QuestionnaireReviewList(items: List) { + LazyColumn { + items( + items = items, + key = { item -> + when (item) { + is QuestionnaireAdapterItem.Question -> item.id + ?: throw IllegalStateException("Missing id for the Question: $item") + is QuestionnaireAdapterItem.RepeatedGroupHeader -> item.id + is QuestionnaireAdapterItem.Navigation -> "navigation" + is QuestionnaireAdapterItem.RepeatedGroupAddButton -> item.id + ?: throw IllegalStateException("Missing id for the RepeatedGroupAddButton: $item") + } + }, + ) { item: QuestionnaireAdapterItem -> + AndroidView( + factory = { context -> + LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + when (item) { + is QuestionnaireAdapterItem.Question -> { + val viewHolder = ReviewViewHolderFactory.create(this) + viewHolder.bind(item.item) + addView(viewHolder.itemView) + } + is QuestionnaireAdapterItem.Navigation -> { + val viewHolder = NavigationViewHolder(inflate(R.layout.pagination_navigation_view)) + viewHolder.bind(item.questionnaireNavigationUIState) + addView(viewHolder.itemView) + } + is QuestionnaireAdapterItem.RepeatedGroupHeader -> { + TODO("Not implemented yet") + } + is QuestionnaireAdapterItem.RepeatedGroupAddButton -> { + TODO("Not implemented yet") + } + } + } + }, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +private fun getQuestionnaireItemViewHolder( parent: ViewGroup, questionnaireViewItem: QuestionnaireViewItem, questionnaireItemViewHolderMatchers: 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 d9442a652a..76c683f6eb 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-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. @@ -19,8 +19,8 @@ package com.google.android.fhir.datacapture /** * Questionnaire item view holder types supported by default by the data capture library. * - * This is used in [QuestionnaireEditAdapter] to determine how each [Questionnaire.Item] is - * rendered. + * This is used by the [QuestionnaireFragment] lists to determine how each + * [org.hl7.fhir.r4.model.Questionnaire.item] is rendered. * * This list should provide sufficient coverage for values in * https://www.hl7.org/fhir/valueset-item-type.html and 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 deleted file mode 100644 index 7f90968396..0000000000 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireEditAdapterTest.kt +++ /dev/null @@ -1,900 +0,0 @@ -/* - * 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. - * 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 - -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 -import com.google.android.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_URL_ANDROID_FHIR -import com.google.android.fhir.datacapture.extensions.ItemControlTypes -import com.google.android.fhir.datacapture.validation.Invalid -import com.google.android.fhir.datacapture.validation.NotValidated -import com.google.android.fhir.datacapture.views.MediaView -import com.google.android.fhir.datacapture.views.QuestionnaireViewItem -import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder -import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolderFactory -import com.google.common.truth.Truth.assertThat -import kotlinx.coroutines.test.runTest -import org.hl7.fhir.r4.model.CodeableConcept -import org.hl7.fhir.r4.model.Coding -import org.hl7.fhir.r4.model.DateType -import org.hl7.fhir.r4.model.Extension -import org.hl7.fhir.r4.model.IntegerType -import org.hl7.fhir.r4.model.Questionnaire -import org.hl7.fhir.r4.model.QuestionnaireResponse -import org.hl7.fhir.r4.model.StringType -import org.junit.Assert.assertThrows -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.kotlin.any -import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [Build.VERSION_CODES.P]) -class QuestionnaireEditAdapterTest { - @Test - fun getItemViewType_groupItemType_shouldReturnGroupViewHolderType() { - val questionnaireEditAdapter = QuestionnaireEditAdapter() - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent() - .setType(Questionnaire.QuestionnaireItemType.GROUP), - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .isEqualTo(QuestionnaireViewHolderType.GROUP.value) - } - - @Test - fun getItemViewType_booleanItemType_shouldReturnBooleanViewHolderType() { - val questionnaireEditAdapter = QuestionnaireEditAdapter() - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent() - .setType(Questionnaire.QuestionnaireItemType.BOOLEAN), - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .isEqualTo(QuestionnaireViewHolderType.BOOLEAN_TYPE_PICKER.value) - } - - @Test - fun getItemViewType_dateItemType_shouldReturnDatePickerViewHolderType() { - val questionnaireEditAdapter = QuestionnaireEditAdapter() - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent() - .setType(Questionnaire.QuestionnaireItemType.DATE), - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .isEqualTo(QuestionnaireViewHolderType.DATE_PICKER.value) - } - - @Test - fun getItemViewType_dateItemType_answerOption_shouldReturnDropDownViewHolderType() { - val questionnaireEditAdapter = QuestionnaireEditAdapter() - val questionnaireItemComponent = - Questionnaire.QuestionnaireItemComponent().apply { - type = Questionnaire.QuestionnaireItemType.DATE - answerOption = - listOf(Questionnaire.QuestionnaireItemAnswerOptionComponent(DateType("2022-06-22"))) - } - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItemComponent, - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .isEqualTo(QuestionnaireViewHolderType.RADIO_GROUP.value) - } - - @Test - fun getItemViewType_dateTimeItemType_shouldReturnDateTimePickerViewHolderType() { - val questionnaireEditAdapter = QuestionnaireEditAdapter() - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent() - .setType(Questionnaire.QuestionnaireItemType.DATETIME), - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .isEqualTo(QuestionnaireViewHolderType.DATE_TIME_PICKER.value) - } - - @Test - fun getItemViewType_stringItemType_shouldReturnEditTextViewHolderType() { - val questionnaireEditAdapter = QuestionnaireEditAdapter() - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent() - .setType(Questionnaire.QuestionnaireItemType.STRING), - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .isEqualTo(QuestionnaireViewHolderType.EDIT_TEXT_SINGLE_LINE.value) - } - - @Suppress("ktlint:standard:max-line-length") - @Test - fun getItemViewType_stringItemType_androidItemControlExtension_shouldReturnPhoneNumberViewHolderType() { - val questionnaireEditAdapter = QuestionnaireEditAdapter() - val questionnaireItem = - Questionnaire.QuestionnaireItemComponent().setType(Questionnaire.QuestionnaireItemType.STRING) - questionnaireItem.addExtension( - Extension() - .setUrl(EXTENSION_ITEM_CONTROL_URL_ANDROID_FHIR) - .setValue( - CodeableConcept() - .addCoding( - Coding() - .setCode(ItemControlTypes.PHONE_NUMBER.extensionCode) - .setSystem(EXTENSION_ITEM_CONTROL_SYSTEM_ANDROID_FHIR), - ), - ), - ) - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .isEqualTo(QuestionnaireViewHolderType.PHONE_NUMBER.value) - } - - @Test - fun getItemViewType_stringItemType_answerOption_shouldReturnDropDownViewHolderType() { - val questionnaireEditAdapter = QuestionnaireEditAdapter() - val questionnaireItemComponent = - Questionnaire.QuestionnaireItemComponent().apply { - type = Questionnaire.QuestionnaireItemType.STRING - answerOption = - listOf(Questionnaire.QuestionnaireItemAnswerOptionComponent(StringType("option-1"))) - } - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItemComponent, - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .isEqualTo(QuestionnaireViewHolderType.RADIO_GROUP.value) - } - - @Test - fun getItemViewType_textItemType_shouldReturnEditTextViewHolderType() { - val questionnaireEditAdapter = QuestionnaireEditAdapter() - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent() - .setType(Questionnaire.QuestionnaireItemType.TEXT), - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .isEqualTo(QuestionnaireViewHolderType.EDIT_TEXT_MULTI_LINE.value) - } - - @Test - fun getItemViewType_integerItemType_shouldReturnEditTextIntegerViewHolderType() { - val questionnaireEditAdapter = QuestionnaireEditAdapter() - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent() - .setType(Questionnaire.QuestionnaireItemType.INTEGER), - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .isEqualTo(QuestionnaireViewHolderType.EDIT_TEXT_INTEGER.value) - } - - @Test - fun getItemViewType_integerItemType_itemControlExtensionWithSlider_shouldReturnSliderViewHolderType() { - val questionnaireEditAdapter = QuestionnaireEditAdapter() - val questionnaireItem = - Questionnaire.QuestionnaireItemComponent() - .setType(Questionnaire.QuestionnaireItemType.INTEGER) - questionnaireItem.addExtension( - Extension() - .setUrl(EXTENSION_ITEM_CONTROL_URL) - .setValue( - CodeableConcept() - .addCoding( - Coding() - .setCode(ItemControlTypes.SLIDER.extensionCode) - .setDisplay("Slider") - .setSystem(EXTENSION_ITEM_CONTROL_SYSTEM), - ), - ), - ) - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .isEqualTo(QuestionnaireViewHolderType.SLIDER.value) - } - - @Test - fun getItemViewType_integerItemType_answerOption_shouldReturnDropDownViewHolderType() { - val questionnaireEditAdapter = QuestionnaireEditAdapter() - val questionnaireItemComponent = - Questionnaire.QuestionnaireItemComponent().apply { - type = Questionnaire.QuestionnaireItemType.INTEGER - answerOption = - listOf(Questionnaire.QuestionnaireItemAnswerOptionComponent(IntegerType("1"))) - } - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItemComponent, - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .isEqualTo(QuestionnaireViewHolderType.RADIO_GROUP.value) - } - - @Test - fun getItemViewType_decimalItemType_shouldReturnEditTextDecimalViewHolderType() { - val questionnaireEditAdapter = QuestionnaireEditAdapter() - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent() - .setType(Questionnaire.QuestionnaireItemType.DECIMAL), - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .isEqualTo(QuestionnaireViewHolderType.EDIT_TEXT_DECIMAL.value) - } - - @Test - fun getItemViewType_choiceItemType_lessAnswerOptions_shouldReturnRadioGroupViewHolderType() { - val questionnaireEditAdapter = QuestionnaireEditAdapter() - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent() - .setType(Questionnaire.QuestionnaireItemType.CHOICE), - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .isEqualTo(QuestionnaireViewHolderType.RADIO_GROUP.value) - } - - @Test - fun getItemViewType_choiceItemType_moreAnswerOptions_shouldReturnDropDownViewHolderType() { - val answerOptions = - List(QuestionnaireEditAdapter.MINIMUM_NUMBER_OF_ANSWER_OPTIONS_FOR_DROP_DOWN) { - Questionnaire.QuestionnaireItemAnswerOptionComponent() - .setValue(Coding().setCode("test-code").setDisplay("Test Code")) - } - val questionnaireEditAdapter = QuestionnaireEditAdapter() - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent() - .setType(Questionnaire.QuestionnaireItemType.CHOICE) - .setAnswerOption(answerOptions), - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .isEqualTo(QuestionnaireViewHolderType.DROP_DOWN.value) - } - - @Suppress("ktlint:standard:max-line-length") - @Test - fun getItemViewType_choiceItemType_itemControlExtensionWithRadioButton_shouldReturnRadioGroupViewHolder() { - val answerOptions = - List(QuestionnaireEditAdapter.MINIMUM_NUMBER_OF_ANSWER_OPTIONS_FOR_DROP_DOWN) { - Questionnaire.QuestionnaireItemAnswerOptionComponent() - .setValue(Coding().setCode("test-code").setDisplay("Test Code")) - } - val questionnaireEditAdapter = QuestionnaireEditAdapter() - val questionnaireItem = - Questionnaire.QuestionnaireItemComponent() - .setType(Questionnaire.QuestionnaireItemType.CHOICE) - .setAnswerOption(answerOptions) - questionnaireItem.addExtension( - Extension() - .setUrl(EXTENSION_ITEM_CONTROL_URL) - .setValue( - CodeableConcept() - .addCoding( - Coding() - .setCode(ItemControlTypes.RADIO_BUTTON.extensionCode) - .setDisplay("Radio Button") - .setSystem(EXTENSION_ITEM_CONTROL_SYSTEM), - ), - ), - ) - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .isEqualTo(QuestionnaireViewHolderType.RADIO_GROUP.value) - } - - @Suppress("ktlint:standard:max-line-length") - @Test - fun getItemViewType_choiceItemType_itemControlExtensionWithDropDown_shouldReturnDropDownViewHolderType() { - val questionnaireEditAdapter = QuestionnaireEditAdapter() - val questionnaireItem = - Questionnaire.QuestionnaireItemComponent().setType(Questionnaire.QuestionnaireItemType.CHOICE) - questionnaireItem.addExtension( - Extension() - .setUrl(EXTENSION_ITEM_CONTROL_URL) - .setValue( - CodeableConcept() - .addCoding( - Coding() - .setCode(ItemControlTypes.DROP_DOWN.extensionCode) - .setDisplay("Drop Down") - .setSystem(EXTENSION_ITEM_CONTROL_SYSTEM), - ), - ), - ) - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .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 - fun `areItemsTheSame() should return false if the questionnaire items are different`() { - val questionnaireItem = Questionnaire.QuestionnaireItemComponent() - val otherQuestionnaireItem = Questionnaire.QuestionnaireItemComponent() - val questionnaireResponseItem = QuestionnaireResponse.QuestionnaireResponseItemComponent() - - assertThat( - DiffCallbacks.ITEMS.areItemsTheSame( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - questionnaireResponseItem, - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - otherQuestionnaireItem, - questionnaireResponseItem, - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - .isFalse() - } - - fun `areItemsTheSame() should return false if the questionnaire response items are different`() { - val questionnaireItem = Questionnaire.QuestionnaireItemComponent() - val questionnaireResponseItem = QuestionnaireResponse.QuestionnaireResponseItemComponent() - val otherQuestionnaireResponseItem = QuestionnaireResponse.QuestionnaireResponseItemComponent() - - assertThat( - DiffCallbacks.ITEMS.areItemsTheSame( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - questionnaireResponseItem, - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - otherQuestionnaireResponseItem, - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - .isFalse() - } - - fun `areItemsTheSame() should return true if the questionnaire item and the questionnaire response item are the same`() { - val questionnaireItem = Questionnaire.QuestionnaireItemComponent() - val questionnaireResponseItem = QuestionnaireResponse.QuestionnaireResponseItemComponent() - - assertThat( - DiffCallbacks.ITEMS.areItemsTheSame( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - questionnaireResponseItem, - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - questionnaireResponseItem, - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - .isTrue() - } - - @Test - fun `areContentsTheSame() should return false if the questionnaire items are different`() { - val questionnaireItem = Questionnaire.QuestionnaireItemComponent() - val otherQuestionnaireItem = Questionnaire.QuestionnaireItemComponent() - val questionnaireResponseItem = QuestionnaireResponse.QuestionnaireResponseItemComponent() - - assertThat( - DiffCallbacks.ITEMS.areContentsTheSame( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - questionnaireResponseItem, - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - otherQuestionnaireItem, - questionnaireResponseItem, - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - .isFalse() - } - - fun `areContentsTheSame() should return false if the questionnaire response items are different`() { - val questionnaireItem = Questionnaire.QuestionnaireItemComponent() - val questionnaireResponseItem = QuestionnaireResponse.QuestionnaireResponseItemComponent() - val otherQuestionnaireResponseItem = QuestionnaireResponse.QuestionnaireResponseItemComponent() - - assertThat( - DiffCallbacks.ITEMS.areContentsTheSame( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - questionnaireResponseItem, - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - otherQuestionnaireResponseItem, - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - .isFalse() - } - - fun `areContentsTheSame() should return false if the answers are different`() { - val questionnaireItem = Questionnaire.QuestionnaireItemComponent() - val questionnaireResponseItem = QuestionnaireResponse.QuestionnaireResponseItemComponent() - - runTest { - assertThat( - DiffCallbacks.ITEMS.areContentsTheSame( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - questionnaireResponseItem, - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - questionnaireResponseItem, - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ) - .apply { - addAnswer( - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { - value = StringType("answer") - }, - ) - }, - ), - ), - ) - .isFalse() - } - } - - fun `areContentsTheSame() should return false if the validation results are different`() { - val questionnaireItem = Questionnaire.QuestionnaireItemComponent() - val questionnaireResponseItem = QuestionnaireResponse.QuestionnaireResponseItemComponent() - - assertThat( - DiffCallbacks.ITEMS.areContentsTheSame( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - questionnaireResponseItem, - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - questionnaireResponseItem, - validationResult = Invalid(listOf()), - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - .isFalse() - } - - fun `areContentsTheSame() should treat not validated and invalid validation results as the same`() { - val questionnaireItem = Questionnaire.QuestionnaireItemComponent() - val questionnaireResponseItem = QuestionnaireResponse.QuestionnaireResponseItemComponent() - - assertThat( - DiffCallbacks.ITEMS.areContentsTheSame( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - questionnaireResponseItem, - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - questionnaireResponseItem, - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - .isTrue() - } - - fun `areContentsTheSame() should return true if the questionnaire, the questionnaire response, the answers, and the validation results are all the same`() { - val questionnaireItem = Questionnaire.QuestionnaireItemComponent() - val questionnaireResponseItem = QuestionnaireResponse.QuestionnaireResponseItemComponent() - - assertThat( - DiffCallbacks.ITEMS.areContentsTheSame( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - questionnaireResponseItem, - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - questionnaireResponseItem, - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - .isTrue() - } - - @Test - fun onCreateViewHolder_customViewType_shouldReturnCorrectCustomViewHolder() { - val viewFactoryMatchers = getQuestionnaireItemViewHolderFactoryMatchers() - val questionnaireEditAdapter = QuestionnaireEditAdapter(viewFactoryMatchers) - val holder = - questionnaireEditAdapter.onCreateViewHolder(mock(), QuestionnaireViewHolderType.values().size) - holder as QuestionnaireEditAdapter.ViewHolder.QuestionHolder - assertThat(holder.holder).isEqualTo(fakeHolder) - } - - @Test - fun onCreateViewHolder_customViewType_shouldThrowExceptionForInvalidWidgetType() { - val viewFactoryMatchers = getQuestionnaireItemViewHolderFactoryMatchers() - val questionnaireEditAdapter = QuestionnaireEditAdapter(viewFactoryMatchers) - assertThrows(IllegalStateException::class.java) { - QuestionnaireEditAdapter(getQuestionnaireItemViewHolderFactoryMatchers()) - questionnaireEditAdapter.onCreateViewHolder( - mock(), - QuestionnaireViewHolderType.values().size + viewFactoryMatchers.size, - ) - } - } - - @Test - fun getItemViewTypeMapping_customViewType_shouldReturnCorrectIntValue() { - val expectedItemViewType = QuestionnaireViewHolderType.values().size - val questionnaireViewItem = - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent().apply { - type = Questionnaire.QuestionnaireItemType.DATE - }, - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ) - - assertThat(expectedItemViewType) - .isEqualTo( - QuestionnaireEditAdapter(getQuestionnaireItemViewHolderFactoryMatchers()) - .getItemViewTypeForQuestion(questionnaireViewItem), - ) - } - - private fun getQuestionnaireItemViewHolderFactoryMatchers(): - List { - return listOf( - QuestionnaireFragment.QuestionnaireItemViewHolderFactoryMatcher( - mock().apply { - whenever(create(any())).thenReturn(fakeHolder) - }, - ) { questionnaireItem -> - questionnaireItem.type == Questionnaire.QuestionnaireItemType.DATE - }, - ) - } - - private val fakeHolder = - QuestionnaireItemViewHolder( - itemView = - FrameLayout(ApplicationProvider.getApplicationContext()).apply { - addView(MediaView(context, null).apply { id = R.id.item_media }) - }, - delegate = mock(), - ) -}