From 9684f903ffcb7d1acf6bee97c5613d406946a325 Mon Sep 17 00:00:00 2001 From: Elly Kitoto Date: Wed, 17 Sep 2025 20:12:03 +0300 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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") }