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 b84a625a10..10b29a0b9d 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 @@ -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. @@ -30,6 +30,7 @@ import androidx.fragment.app.activityViewModels import androidx.fragment.app.setFragmentResult import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.fhir.datacapture.validation.Invalid @@ -38,6 +39,7 @@ import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemView import com.google.android.material.progressindicator.LinearProgressIndicator import kotlinx.coroutines.launch import org.hl7.fhir.r4.model.Questionnaire +import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemType import timber.log.Timber /** @@ -145,7 +147,7 @@ class QuestionnaireFragment : Fragment() { } questionnaireEditRecyclerView.adapter = questionnaireEditAdapter - val linearLayoutManager = LinearLayoutManager(view.context) + val linearLayoutManager = getLayoutManager() questionnaireEditRecyclerView.layoutManager = linearLayoutManager // Animation does work well with views that could gain focus questionnaireEditRecyclerView.itemAnimator = null @@ -186,7 +188,26 @@ class QuestionnaireFragment : Fragment() { is DisplayMode.EditMode -> { // Set items questionnaireReviewRecyclerView.visibility = View.GONE - questionnaireEditAdapter.submitList(state.items) + val itemsToSubmit = + if (viewModel.maxSpanSize != null) { + state.filterEmptyTextItems() + } else { + state.items + } + + (questionnaireEditRecyclerView.layoutManager as? GridLayoutManager)?.spanSizeLookup = + object : GridLayoutManager.SpanSizeLookup() { + override fun getSpanSize(position: Int): Int { + val item = itemsToSubmit[position] + return if (item is QuestionnaireAdapterItem.Question) { + item.item.spanSize ?: viewModel.maxSpanSize!! + } else { + viewModel.maxSpanSize!! + } + } + } + + questionnaireEditAdapter.submitList(itemsToSubmit) questionnaireEditRecyclerView.visibility = View.VISIBLE reviewModeEditButton.visibility = View.GONE @@ -584,6 +605,21 @@ class QuestionnaireFragment : Fragment() { QuestionnaireItemViewHolderFactoryMatchersProvider() { override fun get() = emptyList() } + + private fun getLayoutManager(): LinearLayoutManager { + return if (viewModel.maxSpanSize != null) { + GridLayoutManager(context, viewModel.maxSpanSize!!) + } else { + LinearLayoutManager(context) + } + } + + internal fun QuestionnaireState.filterEmptyTextItems() = + items.filterNot { item -> + item is QuestionnaireAdapterItem.Question && + item.item.questionnaireItem.type == QuestionnaireItemType.GROUP && + item.item.questionText.isNullOrEmpty() + } } /** 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..acb73352df 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 @@ -29,6 +29,7 @@ import com.google.android.fhir.datacapture.enablement.EnablementEvaluator import com.google.android.fhir.datacapture.expressions.EnabledAnswerOptionsEvaluator import com.google.android.fhir.datacapture.extensions.EntryMode import com.google.android.fhir.datacapture.extensions.allItems +import com.google.android.fhir.datacapture.extensions.calculateLCMOfColumnCounts import com.google.android.fhir.datacapture.extensions.calculatedExpression import com.google.android.fhir.datacapture.extensions.copyNestedItemsToChildlessAnswers import com.google.android.fhir.datacapture.extensions.cqfExpression @@ -36,6 +37,7 @@ import com.google.android.fhir.datacapture.extensions.createQuestionnaireRespons import com.google.android.fhir.datacapture.extensions.entryMode import com.google.android.fhir.datacapture.extensions.filterByCodeInNameExtension import com.google.android.fhir.datacapture.extensions.forEachItemPair +import com.google.android.fhir.datacapture.extensions.groupColumnSpanSizeMap import com.google.android.fhir.datacapture.extensions.hasDifferentAnswerSet import com.google.android.fhir.datacapture.extensions.isDisplayItem import com.google.android.fhir.datacapture.extensions.isHelpCode @@ -99,6 +101,9 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat /** The current questionnaire as questions are being answered. */ internal val questionnaire: Questionnaire + internal var maxSpanSize: Int? = null + private var spanSizeMap: MutableMap? = null + init { questionnaire = when { @@ -166,6 +171,10 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat // Add extension for questionnaire launch time stamp questionnaireResponse.launchTimestamp = DateTimeType(Date()) questionnaireResponse.packRepeatedGroups(questionnaire) + questionnaire.calculateLCMOfColumnCounts()?.let { lcmValue -> + maxSpanSize = lcmValue + spanSizeMap = questionnaire.groupColumnSpanSizeMap(lcmValue) + } } /** @@ -978,6 +987,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat ), isHelpCardOpen = isHelpCard && isHelpCardOpen, helpCardStateChangedCallback = helpCardStateChangedCallback, + spanSize = spanSizeMap?.let { it.get(questionnaireItem) }, ), ) add(question) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt index a29e0151e8..58f2fded9e 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.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. @@ -43,6 +43,7 @@ import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.DecimalType import org.hl7.fhir.r4.model.Expression import org.hl7.fhir.r4.model.IntegerType +import org.hl7.fhir.r4.model.PositiveIntType import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent import org.hl7.fhir.r4.model.QuestionnaireResponse @@ -177,6 +178,9 @@ internal const val EXTENSION_VARIABLE_URL = "http://hl7.org/fhir/StructureDefini internal const val ITEM_INITIAL_EXPRESSION_URL: String = "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression" +internal const val EXTENSION_COLUMN_COUNT_URL: String = + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-columnCount" + // ********************************************************************************************** // // // // Rendering extensions: item control, choice orientation, etc. // @@ -345,7 +349,6 @@ internal val QuestionnaireItemComponent.maxValue internal val QuestionnaireItemComponent.maxValueCqfCalculatedValueExpression get() = getExtensionByUrl(MAX_VALUE_EXTENSION_URL)?.value?.cqfCalculatedValueExpression - // ********************************************************************************************** // // // // Additional display utilities: display item control, localized text spanned, // @@ -1050,3 +1053,41 @@ internal fun QuestionnaireItemComponent.readCustomStyleExtension(styleUrl: Style } return null } + +internal fun QuestionnaireItemComponent.getColumnCount(): Int? { + if (this.type != Questionnaire.QuestionnaireItemType.GROUP) return null + + return this.extension + .firstOrNull { it.url == EXTENSION_COLUMN_COUNT_URL } + ?.let { it.value as? PositiveIntType } + ?.value +} + +internal fun Questionnaire.groupColumnSpanSizeMap( + maxSpanSize: Int, +): MutableMap { + val spanSizeMap = mutableMapOf() + this.item + .filter { it.type == Questionnaire.QuestionnaireItemType.GROUP } + .forEach { groupItem -> + val columnCount = groupItem.getColumnCount() + if (columnCount != null) { + groupItem.processGroupItems(columnCount, maxSpanSize, spanSizeMap) + } + } + return spanSizeMap +} + +private fun QuestionnaireItemComponent.processGroupItems( + columnCount: Int, + maxSpanSize: Int, + spanSizeMap: MutableMap, +) { + this.item.forEach { childItem -> + if (childItem.type != Questionnaire.QuestionnaireItemType.GROUP) { + spanSizeMap[childItem] = maxSpanSize / columnCount + } else { + childItem.processGroupItems(columnCount, maxSpanSize, spanSizeMap) + } + } +} diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaires.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaires.kt index 6c0cc0c0ea..02320452f0 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaires.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaires.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. @@ -16,6 +16,7 @@ package com.google.android.fhir.datacapture.extensions +import kotlin.math.abs import org.hl7.fhir.exceptions.FHIRException import org.hl7.fhir.r4.model.CanonicalType import org.hl7.fhir.r4.model.CodeType @@ -228,3 +229,20 @@ private suspend fun forEachItemPair( } } } + +internal fun Questionnaire.calculateLCMOfColumnCounts(): Int? { + val columnCounts = this.item.mapNotNull { it.getColumnCount() } + return if (columnCounts.isNotEmpty()) { + columnCounts.reduce { acc, value -> lcm(acc, value) } + } else { + null + } +} + +private fun lcm(a: Int, b: Int): Int { + return abs(a * b) / gcd(a, b) +} + +private fun gcd(a: Int, b: Int): Int { + return if (b == 0) a else gcd(b, a % b) +} 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..12be0a7bdc 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. @@ -93,6 +93,7 @@ data class QuestionnaireViewItem( val helpCardStateChangedCallback: (Boolean, QuestionnaireResponseItemComponent) -> Unit = { _, _ -> }, + val spanSize: Int? = null, ) { fun getQuestionnaireResponseItem(): QuestionnaireResponseItemComponent = questionnaireResponseItem diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponentsTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponentsTest.kt index beaf16d400..a7fe98c45b 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponentsTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponentsTest.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. @@ -37,6 +37,7 @@ import org.hl7.fhir.r4.model.Expression import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.IntegerType import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.PositiveIntType import org.hl7.fhir.r4.model.Quantity import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse @@ -2595,6 +2596,58 @@ class MoreQuestionnaireItemComponentsTest { assertThat(question.isRepeatedGroup).isFalse() } + @Test + fun `groupItemColumnCount returns correct column count`() { + val questionnaireItemComponent = + Questionnaire.QuestionnaireItemComponent().apply { + type = Questionnaire.QuestionnaireItemType.GROUP + extension = + mutableListOf( + Extension().apply { + url = EXTENSION_COLUMN_COUNT_URL + setValue(PositiveIntType(3)) + }, + ) + } + + assertThat(questionnaireItemComponent.getColumnCount()).isEqualTo(3) + } + + @Test + fun `groupItemColumnCount returns null when column count extension value is not an PositiveIntType`() { + val questionnaireItemComponent = + Questionnaire.QuestionnaireItemComponent().apply { + type = Questionnaire.QuestionnaireItemType.GROUP + extension = + mutableListOf( + Extension().apply { + url = EXTENSION_COLUMN_COUNT_URL + setValue(StringType("invalid")) + }, + ) + } + assertThat(questionnaireItemComponent.getColumnCount()).isNull() + } + + @Test + fun `groupItemColumnCount returns null when GROUP item has no relevant extension`() { + val questionnaireItemComponent = + Questionnaire.QuestionnaireItemComponent().apply { + type = Questionnaire.QuestionnaireItemType.GROUP + extension = mutableListOf() + } + assertThat(questionnaireItemComponent.getColumnCount()).isNull() + } + + @Test + fun `groupItemColumnCount returns null when no GROUP type item exists`() { + val questionnaireItemComponent = + Questionnaire.QuestionnaireItemComponent().apply { + type = Questionnaire.QuestionnaireItemType.BOOLEAN + } + assertThat(questionnaireItemComponent.getColumnCount()).isNull() + } + private val displayCategoryExtensionWithInstructionsCode = Extension().apply { url = EXTENSION_DISPLAY_CATEGORY_URL