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..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 @@ -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. @@ -21,11 +21,15 @@ 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/QuestionnaireFragment.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt index 590339b5e7..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 @@ -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 @@ -33,9 +43,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 +105,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 +151,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 +163,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 +176,7 @@ class QuestionnaireFragment : Fragment() { is DisplayMode.ReviewMode -> { // Set items questionnaireEditRecyclerView.visibility = View.GONE - questionnaireReviewAdapter.submitList( - state.items, - ) - questionnaireReviewRecyclerView.visibility = View.VISIBLE + questionnaireReviewComposeView.visibility = View.VISIBLE reviewModeEditButton.visibility = if (displayMode.showEditButton) { View.VISIBLE @@ -189,7 +200,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 +245,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 +294,48 @@ class QuestionnaireFragment : Fragment() { } } + @Composable + private fun QuestionnaireReviewList(questionerStateFlow: State) { + LazyColumn { + items( + questionerStateFlow.value.items, + 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 -> + 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") + } + } + } + }, + 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/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/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) - } -}