Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rendering multiple questions in the same line. #2789

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -584,6 +605,21 @@ class QuestionnaireFragment : Fragment() {
QuestionnaireItemViewHolderFactoryMatchersProvider() {
override fun get() = emptyList<QuestionnaireItemViewHolderFactoryMatcher>()
}

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()
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@ 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
import com.google.android.fhir.datacapture.extensions.createQuestionnaireResponseItem
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
Expand Down Expand Up @@ -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<QuestionnaireItemComponent, Int>? = null

init {
questionnaire =
when {
Expand Down Expand Up @@ -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)
}
}

/**
Expand Down Expand Up @@ -978,6 +987,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
),
isHelpCardOpen = isHelpCard && isHelpCardOpen,
helpCardStateChangedCallback = helpCardStateChangedCallback,
spanSize = spanSizeMap?.let { it.get(questionnaireItem) },
),
)
add(question)
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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. //
Expand Down Expand Up @@ -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, //
Expand Down Expand Up @@ -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<QuestionnaireItemComponent, Int> {
val spanSizeMap = mutableMapOf<QuestionnaireItemComponent, Int>()
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<QuestionnaireItemComponent, Int>,
) {
this.item.forEach { childItem ->
if (childItem.type != Questionnaire.QuestionnaireItemType.GROUP) {
spanSizeMap[childItem] = maxSpanSize / columnCount
} else {
childItem.processGroupItems(columnCount, maxSpanSize, spanSizeMap)
}
}
}
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Comment on lines +235 to +239
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return if (columnCounts.isNotEmpty()) {
columnCounts.reduce { acc, value -> lcm(acc, value) }
} else {
null
}
return columnCounts.reduceOrNull { acc, value -> lcm(acc, value) }

try this?

}

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)
}
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -93,6 +93,7 @@ data class QuestionnaireViewItem(
val helpCardStateChangedCallback: (Boolean, QuestionnaireResponseItemComponent) -> Unit =
{ _, _ ->
},
val spanSize: Int? = null,
) {

fun getQuestionnaireResponseItem(): QuestionnaireResponseItemComponent = questionnaireResponseItem
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading