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 9d26010034..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 @@ -23,10 +23,13 @@ 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, ReviewAdapterItem + QuestionnaireAdapterItem, ReviewAdapterItem { + 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. */ @@ -37,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 f303da57ea..c74f901f33 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,17 @@ 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.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 +41,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.ReviewViewHolderFactory import com.google.android.material.progressindicator.LinearProgressIndicator import kotlinx.coroutines.launch import org.hl7.fhir.r4.model.Questionnaire @@ -93,8 +103,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 +149,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,9 +161,6 @@ class QuestionnaireFragment : Fragment() { // Animation does work well with views that could gain focus questionnaireEditRecyclerView.itemAnimator = null - questionnaireReviewRecyclerView.adapter = questionnaireReviewAdapter - questionnaireReviewRecyclerView.layoutManager = LinearLayoutManager(view.context) - // Listen to updates from the view model. viewLifecycleOwner.lifecycleScope.launchWhenCreated { viewModel.questionnaireStateFlow.collect { state -> @@ -162,10 +168,9 @@ class QuestionnaireFragment : Fragment() { is DisplayMode.ReviewMode -> { // Set items questionnaireEditRecyclerView.visibility = View.GONE - questionnaireReviewAdapter.submitList( - state.items.filterIsInstance(), - ) - questionnaireReviewRecyclerView.visibility = View.VISIBLE + + questionnaireReviewComposeView.visibility = View.VISIBLE + questionnaireReviewComposeView.setContent { QuestionnaireReviewList(state.items) } reviewModeEditButton.visibility = if (displayMode.showEditButton) { View.VISIBLE @@ -189,7 +194,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 +239,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 +288,53 @@ class QuestionnaireFragment : Fragment() { } } + @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/QuestionnaireReviewAdapter.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireReviewAdapter.kt deleted file mode 100644 index 97506b4a72..0000000000 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireReviewAdapter.kt +++ /dev/null @@ -1,68 +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.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.REVIEW_ITEMS, - ) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return when (viewType) { - VIEW_TYPE_QUESTION -> ReviewViewHolderFactory.create(parent) - VIEW_TYPE_NAVIGATION -> - NavigationViewHolder( - LayoutInflater.from(parent.context) - .inflate(R.layout.pagination_navigation_view, parent, false), - ) - else -> throw IllegalStateException("Invalid view type: $viewType") - } - } - - 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) - } - } - } - - override fun getItemViewType(position: Int): Int = - when (getItem(position)) { - is QuestionnaireAdapterItem.Question -> VIEW_TYPE_QUESTION - is QuestionnaireAdapterItem.Navigation -> VIEW_TYPE_NAVIGATION - else -> super.getItemViewType(position) - } - - companion object { - const val VIEW_TYPE_QUESTION = 1 - const val VIEW_TYPE_NAVIGATION = 2 - } -} 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 4f76294732..7d7d0e935a 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,16 +1018,31 @@ 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}" + } + }, ) } if (questionnaireItem.isRepeatedGroup) { - add(QuestionnaireAdapterItem.RepeatedGroupAddButton(question.item)) + add( + QuestionnaireAdapterItem.RepeatedGroupAddButton( + id = "${question.item.questionnaireItem.linkId}_add_btn", + item = question.item, + ), + ) } } currentPageItems = items 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) - } -}