From c015ebb86f9fc18e709b604ca71ce09267ee99c8 Mon Sep 17 00:00:00 2001 From: Elly Kitoto Date: Wed, 17 Sep 2025 13:15:24 +0300 Subject: [PATCH 1/3] Refactor QuestionnaireReviewRecyclerView to LazyColumn composable Signed-off-by: Elly Kitoto --- .../datacapture/QuestionnaireAdapterItem.kt | 26 +++++- .../fhir/datacapture/QuestionnaireFragment.kt | 70 ++++++++++++--- .../datacapture/QuestionnaireReviewAdapter.kt | 80 ----------------- .../res/layout/questionnaire_fragment.xml | 2 +- .../QuestionnaireReviewAdapterTest.kt | 90 ------------------- 5 files changed, 83 insertions(+), 185 deletions(-) delete mode 100644 datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireReviewAdapter.kt delete mode 100644 datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireReviewAdapterTest.kt 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..7fca648716 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. @@ -16,13 +16,23 @@ package com.google.android.fhir.datacapture +import androidx.compose.ui.text.capitalize +import androidx.compose.ui.text.intl.Locale import com.google.android.fhir.datacapture.views.QuestionnaireViewItem import org.hl7.fhir.r4.model.QuestionnaireResponse /** Various types of rows that can be used in a Questionnaire RecyclerView. */ internal sealed interface QuestionnaireAdapterItem { + + /** Returns a unique key to identify the item row in a composable list view. */ + fun getKey(): String + /** A row for a question in a Questionnaire RecyclerView. */ - data class Question(val item: QuestionnaireViewItem) : QuestionnaireAdapterItem + data class Question(val item: QuestionnaireViewItem) : QuestionnaireAdapterItem { + override fun getKey(): String { + return item.questionnaireItem.linkId + } + } /** A row for a repeated group response instance's header. */ data class RepeatedGroupHeader( @@ -33,8 +43,16 @@ internal sealed interface QuestionnaireAdapterItem { /** Responses nested under this header. */ val responses: List, val title: String, - ) : QuestionnaireAdapterItem + ) : QuestionnaireAdapterItem { + override fun getKey(): String { + return "RepeatedGroupHeader${title.capitalize(Locale.current)}-$index" + } + } data class Navigation(val questionnaireNavigationUIState: QuestionnaireNavigationUIState) : - QuestionnaireAdapterItem + QuestionnaireAdapterItem { + override fun getKey(): String { + return "QuestionnaireNavigationUIState" + } + } } 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..9c3efee240 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,19 @@ 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.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +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 @@ -36,6 +46,7 @@ import androidx.recyclerview.widget.RecyclerView 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.ReviewViewHolderFactory import com.google.android.material.progressindicator.LinearProgressIndicator import kotlinx.coroutines.launch import org.hl7.fhir.r4.model.Questionnaire @@ -93,8 +104,8 @@ class QuestionnaireFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { val questionnaireEditRecyclerView = view.findViewById(R.id.questionnaire_edit_recycler_view) - val questionnaireReviewRecyclerView = - view.findViewById(R.id.questionnaire_review_recycler_view) + val questionnaireReviewComposeView = + view.findViewById(R.id.questionnaire_review_recycler_view) val questionnaireTitle = view.findViewById(R.id.questionnaire_title) // This container frame floats at the bottom of the view to make navigation controls visible at @@ -139,7 +150,6 @@ class QuestionnaireFragment : Fragment() { view.findViewById(R.id.questionnaire_progress_indicator) val questionnaireEditAdapter = QuestionnaireEditAdapter(questionnaireItemViewHolderFactoryMatchersProvider.get()) - val questionnaireReviewAdapter = QuestionnaireReviewAdapter() val reviewModeEditButton = view.findViewById(R.id.review_mode_edit_button).apply { @@ -152,8 +162,11 @@ class QuestionnaireFragment : Fragment() { // Animation does work well with views that could gain focus questionnaireEditRecyclerView.itemAnimator = null - questionnaireReviewRecyclerView.adapter = questionnaireReviewAdapter - questionnaireReviewRecyclerView.layoutManager = LinearLayoutManager(view.context) + questionnaireReviewComposeView.setContent { + val questionerStateFlow = viewModel.questionnaireStateFlow.collectAsState() + + QuestionnaireReviewList(questionerStateFlow) + } // Listen to updates from the view model. viewLifecycleOwner.lifecycleScope.launchWhenCreated { @@ -162,10 +175,10 @@ class QuestionnaireFragment : Fragment() { is DisplayMode.ReviewMode -> { // Set items questionnaireEditRecyclerView.visibility = View.GONE - questionnaireReviewAdapter.submitList( + /* questionnaireReviewAdapter.submitList( state.items, - ) - questionnaireReviewRecyclerView.visibility = View.VISIBLE + )*/ + questionnaireReviewComposeView.visibility = View.VISIBLE reviewModeEditButton.visibility = if (displayMode.showEditButton) { View.VISIBLE @@ -189,7 +202,7 @@ class QuestionnaireFragment : Fragment() { } is DisplayMode.EditMode -> { // Set items - questionnaireReviewRecyclerView.visibility = View.GONE + questionnaireReviewComposeView.visibility = View.GONE questionnaireEditAdapter.submitList(state.items) questionnaireEditRecyclerView.visibility = View.VISIBLE reviewModeEditButton.visibility = View.GONE @@ -234,7 +247,7 @@ class QuestionnaireFragment : Fragment() { } } is DisplayMode.InitMode -> { - questionnaireReviewRecyclerView.visibility = View.GONE + questionnaireReviewComposeView.visibility = View.GONE questionnaireEditRecyclerView.visibility = View.GONE questionnaireProgressIndicator.visibility = View.GONE reviewModeEditButton.visibility = View.GONE @@ -283,6 +296,43 @@ class QuestionnaireFragment : Fragment() { } } + @Composable + private fun QuestionnaireReviewList(questionerStateFlow: State) { + LazyColumn { + items( + questionerStateFlow.value.items, + key = { it.getKey() }, + ) { item: QuestionnaireAdapterItem -> + AndroidView( + factory = { context -> + val linearLayout = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } + + when (item) { + is QuestionnaireAdapterItem.Question -> { + val viewHolder = ReviewViewHolderFactory.create(linearLayout) + viewHolder.bind(item.item) + linearLayout.apply { addView(viewHolder.itemView) } + } + is QuestionnaireAdapterItem.Navigation -> { + val viewHolder = + NavigationViewHolder( + LayoutInflater.from(context) + .inflate(R.layout.pagination_navigation_view, null, false), + ) + viewHolder.bind(item.questionnaireNavigationUIState) + linearLayout.apply { addView(viewHolder.itemView) } + } + is QuestionnaireAdapterItem.RepeatedGroupHeader -> { + 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/QuestionnaireReviewAdapter.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireReviewAdapter.kt deleted file mode 100644 index 7f323562c2..0000000000 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireReviewAdapter.kt +++ /dev/null @@ -1,80 +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.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -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.ReviewViewHolderFactory - -/** List Adapter used to bind answers to [QuestionnaireItemViewHolder] in review mode. */ -internal class QuestionnaireReviewAdapter : - ListAdapter( - DiffCallbacks.ITEMS, - ) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - val typedViewType = QuestionnaireEditAdapter.ViewType.parse(viewType) - return when (typedViewType.type) { - QuestionnaireEditAdapter.ViewType.Type.QUESTION -> ReviewViewHolderFactory.create(parent) - QuestionnaireEditAdapter.ViewType.Type.NAVIGATION -> - NavigationViewHolder( - LayoutInflater.from(parent.context) - .inflate(R.layout.pagination_navigation_view, parent, false), - ) - QuestionnaireEditAdapter.ViewType.Type.REPEATED_GROUP_HEADER -> TODO() - } - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (val item = getItem(position)) { - is QuestionnaireAdapterItem.Question -> { - holder as QuestionnaireItemViewHolder - holder.bind(item.item) - } - is QuestionnaireAdapterItem.Navigation -> { - holder as NavigationViewHolder - holder.bind(item.questionnaireNavigationUIState) - } - is QuestionnaireAdapterItem.RepeatedGroupHeader -> TODO() - } - } - - 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: QuestionnaireEditAdapter.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 (getItem(position)) { - is QuestionnaireAdapterItem.Question -> { - type = QuestionnaireEditAdapter.ViewType.Type.QUESTION - subtype = 0xFFFFFF - } - is QuestionnaireAdapterItem.Navigation -> { - type = QuestionnaireEditAdapter.ViewType.Type.NAVIGATION - subtype = 0xFFFFFF - } - is QuestionnaireAdapterItem.RepeatedGroupHeader -> TODO() - } - return QuestionnaireEditAdapter.ViewType.from(type = type, subtype = subtype).viewType - } -} diff --git a/datacapture/src/main/res/layout/questionnaire_fragment.xml b/datacapture/src/main/res/layout/questionnaire_fragment.xml index 986e51e74d..73e65c380a 100644 --- a/datacapture/src/main/res/layout/questionnaire_fragment.xml +++ b/datacapture/src/main/res/layout/questionnaire_fragment.xml @@ -75,7 +75,7 @@ app:layout_constraintTop_toBottomOf="@+id/questionnaire_progress_indicator" /> - }, - ), - ), - ), - ) - - assertThat(questionnaireReviewAdapter.itemCount).isEqualTo(1) - } - - @Test - fun `submitting multiple items to adapter should return itemCount greater than zero`() { - val questionnaireReviewAdapter = QuestionnaireReviewAdapter() - questionnaireReviewAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent() - .setType(Questionnaire.QuestionnaireItemType.GROUP), - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = Valid, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent() - .setType(Questionnaire.QuestionnaireItemType.DISPLAY), - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = Valid, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireReviewAdapter.itemCount).isEqualTo(2) - assertThat(questionnaireReviewAdapter.itemCount).isGreaterThan(0) - } -} From e40a4472bca1f77db290e3828fb1b81053ca82d3 Mon Sep 17 00:00:00 2001 From: Elly Kitoto Date: Wed, 17 Sep 2025 13:19:05 +0300 Subject: [PATCH 2/3] Delete unused code Signed-off-by: Elly Kitoto --- .../google/android/fhir/datacapture/QuestionnaireFragment.kt | 3 --- 1 file changed, 3 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 9c3efee240..8bda71f741 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 @@ -175,9 +175,6 @@ class QuestionnaireFragment : Fragment() { is DisplayMode.ReviewMode -> { // Set items questionnaireEditRecyclerView.visibility = View.GONE - /* questionnaireReviewAdapter.submitList( - state.items, - )*/ questionnaireReviewComposeView.visibility = View.VISIBLE reviewModeEditButton.visibility = if (displayMode.showEditButton) { From 81ad36feacc27e9e101b6e953a04b0ff901aad21 Mon Sep 17 00:00:00 2001 From: Elly Kitoto Date: Mon, 29 Sep 2025 16:03:34 +0300 Subject: [PATCH 3/3] Refactor how key provision for questionnaire review lazycolumn Signed-off-by: Elly Kitoto --- .../datacapture/QuestionnaireAdapterItem.kt | 22 ++------- .../fhir/datacapture/QuestionnaireFragment.kt | 46 +++++++++++-------- .../datacapture/QuestionnaireViewModel.kt | 21 +++++++-- 3 files changed, 46 insertions(+), 43 deletions(-) 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 7fca648716..d687f96c58 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 @@ -16,26 +16,20 @@ package com.google.android.fhir.datacapture -import androidx.compose.ui.text.capitalize -import androidx.compose.ui.text.intl.Locale import com.google.android.fhir.datacapture.views.QuestionnaireViewItem import org.hl7.fhir.r4.model.QuestionnaireResponse /** Various types of rows that can be used in a Questionnaire RecyclerView. */ internal sealed interface QuestionnaireAdapterItem { - /** Returns a unique key to identify the item row in a composable list view. */ - fun getKey(): String - /** A row for a question in a Questionnaire RecyclerView. */ data class Question(val item: QuestionnaireViewItem) : QuestionnaireAdapterItem { - override fun getKey(): String { - return item.questionnaireItem.linkId - } + 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. */ @@ -43,16 +37,8 @@ internal sealed interface QuestionnaireAdapterItem { /** Responses nested under this header. */ val responses: List, val title: String, - ) : QuestionnaireAdapterItem { - override fun getKey(): String { - return "RepeatedGroupHeader${title.capitalize(Locale.current)}-$index" - } - } + ) : QuestionnaireAdapterItem data class Navigation(val questionnaireNavigationUIState: QuestionnaireNavigationUIState) : - QuestionnaireAdapterItem { - override fun getKey(): String { - return "QuestionnaireNavigationUIState" - } - } + 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 8bda71f741..4a722546d6 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 @@ -43,6 +43,7 @@ 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 @@ -298,29 +299,34 @@ class QuestionnaireFragment : Fragment() { LazyColumn { items( questionerStateFlow.value.items, - key = { it.getKey() }, + 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" + } + }, ) { item: QuestionnaireAdapterItem -> AndroidView( factory = { context -> - val linearLayout = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } - - when (item) { - is QuestionnaireAdapterItem.Question -> { - val viewHolder = ReviewViewHolderFactory.create(linearLayout) - viewHolder.bind(item.item) - linearLayout.apply { addView(viewHolder.itemView) } - } - is QuestionnaireAdapterItem.Navigation -> { - val viewHolder = - NavigationViewHolder( - LayoutInflater.from(context) - .inflate(R.layout.pagination_navigation_view, null, false), - ) - viewHolder.bind(item.questionnaireNavigationUIState) - linearLayout.apply { addView(viewHolder.itemView) } - } - is QuestionnaireAdapterItem.RepeatedGroupHeader -> { - TODO("Not implemented yet") + 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") + } } } }, 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}" + } + }, ) } }