From 57bdd883f1147548734350c1e3454a44b17842ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=E2=89=A1ZRS?= <12814349+LZRS@users.noreply.github.com> Date: Sat, 23 Aug 2025 19:58:23 +0300 Subject: [PATCH 1/4] Move the add button for repeated groups to bottom --- .../component_non_repeated_group.json | 24 +++++ .../test/QuestionnaireUiEspressoTest.kt | 11 ++- .../datacapture/QuestionnaireAdapterItem.kt | 6 +- .../datacapture/QuestionnaireEditAdapter.kt | 30 +++++- .../datacapture/QuestionnaireReviewAdapter.kt | 5 +- .../datacapture/QuestionnaireViewModel.kt | 4 + .../views/RepeatsGroupAddItemViewHolder.kt | 66 +++++++++++++ .../views/factories/GroupViewHolderFactory.kt | 25 +---- .../src/main/res/layout/add_repeated_item.xml | 17 ++++ .../src/main/res/layout/group_header_view.xml | 8 -- .../datacapture/QuestionnaireViewModelTest.kt | 30 +++--- .../RepeatsGroupAddItemViewHolderTest.kt | 95 +++++++++++++++++++ .../factories/GroupViewHolderFactoryTest.kt | 69 +------------- 13 files changed, 275 insertions(+), 115 deletions(-) create mode 100644 datacapture/sampledata/component_non_repeated_group.json create mode 100644 datacapture/src/main/java/com/google/android/fhir/datacapture/views/RepeatsGroupAddItemViewHolder.kt create mode 100644 datacapture/src/main/res/layout/add_repeated_item.xml create mode 100644 datacapture/src/test/java/com/google/android/fhir/datacapture/views/RepeatsGroupAddItemViewHolderTest.kt diff --git a/datacapture/sampledata/component_non_repeated_group.json b/datacapture/sampledata/component_non_repeated_group.json new file mode 100644 index 0000000000..01268f4df1 --- /dev/null +++ b/datacapture/sampledata/component_non_repeated_group.json @@ -0,0 +1,24 @@ +{ + "resourceType": "Questionnaire", + "item": [ + { + "linkId": "1", + "type": "group", + "text": "Group", + "repeats": false, + "item": [ + { + "linkId": "1-1", + "text": "Sample date question", + "type": "date", + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/entryFormat", + "valueString": "yyyy-mm-dd" + } + ] + } + ] + } + ] +} 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..81f805913e 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. @@ -28,6 +28,7 @@ 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.assertion.ViewAssertions.doesNotExist import androidx.test.espresso.contrib.RecyclerViewActions import androidx.test.espresso.matcher.RootMatchers import androidx.test.espresso.matcher.ViewMatchers @@ -610,6 +611,12 @@ class QuestionnaireUiEspressoTest { } } + @Test + fun test_add_item_button_does_not_exist_for_non_repeated_groups() { + buildFragmentFromQuestionnaire("/component_non_repeated_group.json") + onView(withId(R.id.add_item)).check(doesNotExist()) + } + @Test fun test_repeated_group_is_added() { buildFragmentFromQuestionnaire("/component_repeated_group.json") @@ -617,7 +624,7 @@ class QuestionnaireUiEspressoTest { onView(withId(R.id.questionnaire_edit_recycler_view)) .perform( RecyclerViewActions.actionOnItemAtPosition( - 0, + 1, clickChildViewWithId(R.id.add_item), ), ) 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..6a839dc640 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. @@ -35,6 +35,10 @@ internal sealed interface QuestionnaireAdapterItem { val title: String, ) : QuestionnaireAdapterItem + data class RepeatedGroupAddButton( + val item: QuestionnaireViewItem, + ) : QuestionnaireAdapterItem + data class Navigation(val questionnaireNavigationUIState: QuestionnaireNavigationUIState) : QuestionnaireAdapterItem } diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditAdapter.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditAdapter.kt index 6fc427eae2..fa5af6107f 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditAdapter.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditAdapter.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. @@ -28,6 +28,7 @@ import com.google.android.fhir.datacapture.extensions.itemControl import com.google.android.fhir.datacapture.extensions.shouldUseDialog import com.google.android.fhir.datacapture.views.NavigationViewHolder import com.google.android.fhir.datacapture.views.QuestionnaireViewItem +import com.google.android.fhir.datacapture.views.RepeatsGroupAddItemViewHolder 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 @@ -80,6 +81,11 @@ internal class QuestionnaireEditAdapter( ), ) } + ViewType.Type.REPEATED_GROUP_ADD_BUTTON -> { + ViewHolder.RepeatedGroupAddButtonViewHolder( + RepeatsGroupAddItemViewHolder.create(parent), + ) + } } } @@ -138,6 +144,10 @@ internal class QuestionnaireEditAdapter( holder as ViewHolder.NavigationHolder holder.viewHolder.bind(item.questionnaireNavigationUIState) } + is QuestionnaireAdapterItem.RepeatedGroupAddButton -> { + holder as ViewHolder.RepeatedGroupAddButtonViewHolder + holder.viewHolder.bind(item.item) + } } } @@ -163,6 +173,10 @@ internal class QuestionnaireEditAdapter( type = ViewType.Type.NAVIGATION subtype = 0xFFFFFF } + is QuestionnaireAdapterItem.RepeatedGroupAddButton -> { + type = ViewType.Type.REPEATED_GROUP_ADD_BUTTON + subtype = 0 + } } return ViewType.from(type = type, subtype = subtype).viewType } @@ -194,6 +208,7 @@ internal class QuestionnaireEditAdapter( enum class Type { QUESTION, REPEATED_GROUP_HEADER, + REPEATED_GROUP_ADD_BUTTON, NAVIGATION, } } @@ -296,6 +311,9 @@ internal class QuestionnaireEditAdapter( ViewHolder(viewHolder.itemView) class NavigationHolder(val viewHolder: NavigationViewHolder) : ViewHolder(viewHolder.itemView) + + class RepeatedGroupAddButtonViewHolder(val viewHolder: RepeatsGroupAddItemViewHolder) : + ViewHolder(viewHolder.itemView) } internal companion object { @@ -324,6 +342,10 @@ internal object DiffCallbacks { oldItem.index == newItem.index } is QuestionnaireAdapterItem.Navigation -> newItem is QuestionnaireAdapterItem.Navigation + is QuestionnaireAdapterItem.RepeatedGroupAddButton -> { + newItem is QuestionnaireAdapterItem.RepeatedGroupAddButton && + oldItem.item.hasTheSameItem(newItem.item) + } } override fun areContentsTheSame( @@ -363,6 +385,12 @@ internal object DiffCallbacks { newItem is QuestionnaireAdapterItem.Navigation && oldItem.questionnaireNavigationUIState == newItem.questionnaireNavigationUIState } + is QuestionnaireAdapterItem.RepeatedGroupAddButton -> { + newItem is QuestionnaireAdapterItem.RepeatedGroupAddButton && + oldItem.item.hasTheSameItem(newItem.item) && + oldItem.item.hasTheSameResponse(newItem.item) && + oldItem.item.hasTheSameValidationResult(newItem.item) + } } } 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 index 7f323562c2..f22e2f3514 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireReviewAdapter.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireReviewAdapter.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. @@ -39,6 +39,7 @@ internal class QuestionnaireReviewAdapter : .inflate(R.layout.pagination_navigation_view, parent, false), ) QuestionnaireEditAdapter.ViewType.Type.REPEATED_GROUP_HEADER -> TODO() + QuestionnaireEditAdapter.ViewType.Type.REPEATED_GROUP_ADD_BUTTON -> TODO() } } @@ -53,6 +54,7 @@ internal class QuestionnaireReviewAdapter : holder.bind(item.questionnaireNavigationUIState) } is QuestionnaireAdapterItem.RepeatedGroupHeader -> TODO() + is QuestionnaireAdapterItem.RepeatedGroupAddButton -> TODO() } } @@ -74,6 +76,7 @@ internal class QuestionnaireReviewAdapter : subtype = 0xFFFFFF } is QuestionnaireAdapterItem.RepeatedGroupHeader -> TODO() + is QuestionnaireAdapterItem.RepeatedGroupAddButton -> 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..4f76294732 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 @@ -1024,6 +1024,10 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat ), ) } + + if (questionnaireItem.isRepeatedGroup) { + add(QuestionnaireAdapterItem.RepeatedGroupAddButton(question.item)) + } } currentPageItems = items return items diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/RepeatsGroupAddItemViewHolder.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/RepeatsGroupAddItemViewHolder.kt new file mode 100644 index 0000000000..46c2ae5a5b --- /dev/null +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/RepeatsGroupAddItemViewHolder.kt @@ -0,0 +1,66 @@ +/* + * Copyright 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.views + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.RecyclerView +import com.google.android.fhir.datacapture.R +import com.google.android.fhir.datacapture.extensions.tryUnwrapContext +import kotlinx.coroutines.launch +import org.hl7.fhir.r4.model.QuestionnaireResponse + +class RepeatsGroupAddItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + + private var context: AppCompatActivity = itemView.context.tryUnwrapContext()!! + + fun bind(questionnaireViewItem: QuestionnaireViewItem) { + val addItemButton: Button = itemView.findViewById(R.id.add_item) + + addItemButton.text = + itemView.context.getString( + R.string.add_repeated_group_item, + questionnaireViewItem.questionText ?: "", + ) + addItemButton.visibility = + if (questionnaireViewItem.questionnaireItem.repeats) View.VISIBLE else View.GONE + addItemButton.setOnClickListener { + context.lifecycleScope.launch { + questionnaireViewItem.addAnswer( + // Nested items will be added in answerChangedCallback in the QuestionnaireViewModel + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent(), + ) + } + } + + addItemButton.isEnabled = !questionnaireViewItem.questionnaireItem.readOnly + } + + companion object { + val layoutRes = R.layout.add_repeated_item + + fun create(parent: ViewGroup): RepeatsGroupAddItemViewHolder { + return RepeatsGroupAddItemViewHolder( + LayoutInflater.from(parent.context).inflate(layoutRes, parent, false), + ) + } + } +} diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/GroupViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/GroupViewHolderFactory.kt index 73243fa0bc..299dfe60de 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/GroupViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/GroupViewHolderFactory.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. @@ -17,10 +17,8 @@ package com.google.android.fhir.datacapture.views.factories import android.view.View -import android.widget.Button import android.widget.TextView import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.lifecycleScope import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.extensions.tryUnwrapContext import com.google.android.fhir.datacapture.validation.Invalid @@ -29,8 +27,6 @@ import com.google.android.fhir.datacapture.validation.Valid import com.google.android.fhir.datacapture.validation.ValidationResult import com.google.android.fhir.datacapture.views.GroupHeaderView import com.google.android.fhir.datacapture.views.QuestionnaireViewItem -import kotlinx.coroutines.launch -import org.hl7.fhir.r4.model.QuestionnaireResponse internal object GroupViewHolderFactory : QuestionnaireItemViewHolderFactory(R.layout.group_header_view) { @@ -39,33 +35,16 @@ internal object GroupViewHolderFactory : private lateinit var context: AppCompatActivity private lateinit var header: GroupHeaderView private lateinit var error: TextView - private lateinit var addItemButton: Button override lateinit var questionnaireViewItem: QuestionnaireViewItem override fun init(itemView: View) { context = itemView.context.tryUnwrapContext()!! header = itemView.findViewById(R.id.header) error = itemView.findViewById(R.id.error) - addItemButton = itemView.findViewById(R.id.add_item) } override fun bind(questionnaireViewItem: QuestionnaireViewItem) { header.bind(questionnaireViewItem) - addItemButton.text = - context.getString( - R.string.add_repeated_group_item, - questionnaireViewItem.questionText ?: "", - ) - addItemButton.visibility = - if (questionnaireViewItem.questionnaireItem.repeats) View.VISIBLE else View.GONE - addItemButton.setOnClickListener { - context.lifecycleScope.launch { - questionnaireViewItem.addAnswer( - // Nested items will be added in answerChangedCallback in the QuestionnaireViewModel - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent(), - ) - } - } displayValidationResult(questionnaireViewItem.validationResult) } @@ -81,7 +60,7 @@ internal object GroupViewHolderFactory : } override fun setReadOnly(isReadOnly: Boolean) { - addItemButton.isEnabled = !isReadOnly + // No-op } } } diff --git a/datacapture/src/main/res/layout/add_repeated_item.xml b/datacapture/src/main/res/layout/add_repeated_item.xml new file mode 100644 index 0000000000..2dd8a0fbd7 --- /dev/null +++ b/datacapture/src/main/res/layout/add_repeated_item.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/datacapture/src/main/res/layout/group_header_view.xml b/datacapture/src/main/res/layout/group_header_view.xml index f8e58a23c0..ee87e23163 100644 --- a/datacapture/src/main/res/layout/group_header_view.xml +++ b/datacapture/src/main/res/layout/group_header_view.xml @@ -50,12 +50,4 @@ android:layout_height="wrap_content" /> - - diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelTest.kt index 659429a95a..cc9f73cb71 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelTest.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. @@ -4654,6 +4654,7 @@ class QuestionnaireViewModelTest { is QuestionnaireAdapterItem.Question -> it.item.questionnaireItem.linkId is QuestionnaireAdapterItem.RepeatedGroupHeader -> "RepeatedGroupHeader:${it.index}" is QuestionnaireAdapterItem.Navigation -> TODO() + is QuestionnaireAdapterItem.RepeatedGroupAddButton -> "Add repeated group item" } }, ) @@ -4665,6 +4666,7 @@ class QuestionnaireViewModelTest { "RepeatedGroupHeader:1", "nested-item-a", "another-nested-item-a", + "Add repeated group item", "repeated-group-b", "RepeatedGroupHeader:0", "nested-item-b", @@ -4672,6 +4674,7 @@ class QuestionnaireViewModelTest { "RepeatedGroupHeader:1", "nested-item-b", "another-nested-item-b", + "Add repeated group item", ) .inOrder() @@ -4905,16 +4908,21 @@ class QuestionnaireViewModelTest { val viewModel = createQuestionnaireViewModel(questionnaire) viewModel.runViewModelBlocking { - viewModel.getQuestionnaireItemViewItemList().single().asQuestion().apply { - this.answersChangedCallback( - this.questionnaireItem, - this.getQuestionnaireResponseItem(), - listOf( - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent(), - ), - null, - ) - } + viewModel + .getQuestionnaireItemViewItemList() + .filterIsInstance() + .single() + .asQuestion() + .apply { + this.answersChangedCallback( + this.questionnaireItem, + this.getQuestionnaireResponseItem(), + listOf( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent(), + ), + null, + ) + } assertThat( viewModel diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/RepeatsGroupAddItemViewHolderTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/RepeatsGroupAddItemViewHolderTest.kt new file mode 100644 index 0000000000..3c965c79a7 --- /dev/null +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/RepeatsGroupAddItemViewHolderTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright 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.views + +import android.view.View +import android.widget.Button +import android.widget.FrameLayout +import androidx.appcompat.app.AppCompatActivity +import com.google.android.fhir.datacapture.R +import com.google.android.fhir.datacapture.validation.NotValidated +import com.google.common.truth.Truth.assertThat +import org.hl7.fhir.r4.model.Questionnaire +import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class RepeatsGroupAddItemViewHolderTest { + + private val parent = + FrameLayout( + Robolectric.buildActivity(AppCompatActivity::class.java).create().get().apply { + setTheme(com.google.android.material.R.style.Theme_Material3_DayNight) + }, + ) + private val viewHolder: RepeatsGroupAddItemViewHolder = + RepeatsGroupAddItemViewHolder.create(parent) + + @Test + fun testRepeatedGroupIsReadOnlyDisablesAddButton() { + viewHolder.bind( + QuestionnaireViewItem( + Questionnaire.QuestionnaireItemComponent().apply { + text = "Question?" + type = Questionnaire.QuestionnaireItemType.GROUP + repeats = true + readOnly = true + }, + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + assertThat((viewHolder.itemView.findViewById