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..45e07cc557 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 @@ -155,6 +155,7 @@ class QuestionnaireFragment : Fragment() { // Listen to updates from the view model. viewLifecycleOwner.lifecycleScope.launchWhenCreated { + viewModel.pages = viewModel.getQuestionnairePages() viewModel.questionnaireStateFlow.collect { state -> when (val displayMode = state.displayMode) { is DisplayMode.ReviewMode -> { diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireNavigationViewUIState.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireNavigationViewUIState.kt index 60bd9e1c7b..ce3b425443 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireNavigationViewUIState.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireNavigationViewUIState.kt @@ -16,10 +16,12 @@ package com.google.android.fhir.datacapture +import androidx.annotation.StringRes + sealed interface QuestionnaireNavigationViewUIState { data object Hidden : QuestionnaireNavigationViewUIState - data class Enabled(val labelText: String? = null, val onClickAction: () -> Unit) : + data class Enabled(val labelText: String? = null, @StringRes val labelStringRes: Int? = null, val onClickAction: () -> Unit = {}) : QuestionnaireNavigationViewUIState } @@ -29,4 +31,6 @@ data class QuestionnaireNavigationUIState( val navSubmit: QuestionnaireNavigationViewUIState = QuestionnaireNavigationViewUIState.Hidden, val navCancel: QuestionnaireNavigationViewUIState = QuestionnaireNavigationViewUIState.Hidden, val navReview: QuestionnaireNavigationViewUIState = QuestionnaireNavigationViewUIState.Hidden, + val navNextProgressBar: QuestionnaireNavigationViewUIState = + QuestionnaireNavigationViewUIState.Hidden, ) 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..f29112b3da 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 @@ -35,9 +35,12 @@ 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.flattened import com.google.android.fhir.datacapture.extensions.forEachItemPair import com.google.android.fhir.datacapture.extensions.hasDifferentAnswerSet import com.google.android.fhir.datacapture.extensions.isDisplayItem +import com.google.android.fhir.datacapture.extensions.isEnableWhenReferencedBy +import com.google.android.fhir.datacapture.extensions.isExpressionReferencedBy import com.google.android.fhir.datacapture.extensions.isHelpCode import com.google.android.fhir.datacapture.extensions.isHidden import com.google.android.fhir.datacapture.extensions.isPaginated @@ -66,6 +69,7 @@ import com.google.android.fhir.datacapture.validation.ValidationResult import com.google.android.fhir.datacapture.views.QuestionTextConfiguration import com.google.android.fhir.datacapture.views.QuestionnaireViewItem import java.util.Date +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -323,6 +327,8 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat */ private val draftAnswerMap = mutableMapOf() + private val isLoadingNextPage = MutableStateFlow(true) + /** * Callback function to update the view model after the answer(s) to a question have been changed. * This is passed to the [QuestionnaireViewItem] in its constructor so that it can invoke this @@ -377,10 +383,28 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat ) } modifiedQuestionnaireResponseItemSet.add(questionnaireResponseItem) + viewModelScope.launch(Dispatchers.IO) { + var isReferenced = false + kotlin.run { + isReferenced = questionnaireItem.isExpressionReferencedBy(questionnaire) + if (isReferenced) return@run + + questionnaire.item.flattened().forEach { item -> + isReferenced = questionnaireItem.isEnableWhenReferencedBy(item) + if (isReferenced) return@run + + isReferenced = questionnaireItem.isExpressionReferencedBy(item) + if (isReferenced) return@run + } + } + if (isReferenced) isLoadingNextPage.value = true + modificationCount.update { it + 1 } - updateAnswerWithAffectedCalculatedExpression(questionnaireItem) - - modificationCount.update { it + 1 } + updateAnswerWithAffectedCalculatedExpression(questionnaireItem) + pages = getQuestionnairePages() + isLoadingNextPage.value = false + modificationCount.update { it + 1 } + } } private val expressionEvaluator: ExpressionEvaluator = @@ -592,6 +616,8 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat .onEach { if (it.index == 0) { initializeCalculatedExpressions() + pages = getQuestionnairePages() + isLoadingNextPage.value = false modificationCount.update { count -> count + 1 } } } @@ -718,7 +744,6 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat // display all items. val questionnaireItemViewItems = if (!isReadOnly && !isInReviewModeFlow.value && questionnaire.isPaginated) { - pages = getQuestionnairePages() if (currentPageIndexFlow.value == null) { currentPageIndexFlow.value = pages!!.first { it.enabled && !it.hidden }.index } @@ -738,8 +763,8 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat navSubmit = if (showSubmitButton) { QuestionnaireNavigationViewUIState.Enabled( - submitButtonText, - onSubmitButtonClickListener, + labelText = submitButtonText, + onClickAction = onSubmitButtonClickListener, ) } else { QuestionnaireNavigationViewUIState.Hidden @@ -747,6 +772,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat navCancel = if (!isReadOnly && shouldShowCancelButton) { QuestionnaireNavigationViewUIState.Enabled( + labelStringRes = R.string.cancel_questionnaire, onClickAction = onCancelButtonClickListener, ) } else { @@ -804,7 +830,10 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat navPrevious = when { questionnairePagination.isPaginated && questionnairePagination.hasPreviousPage -> { - QuestionnaireNavigationViewUIState.Enabled { goToPreviousPage() } + QuestionnaireNavigationViewUIState.Enabled( + labelStringRes = R.string.button_pagination_previous, + onClickAction = { goToPreviousPage() }, + ) } else -> { QuestionnaireNavigationViewUIState.Hidden @@ -812,8 +841,16 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat }, navNext = when { + questionnairePagination.isPaginated && + questionnairePagination.hasNextPage && + isLoadingNextPage.value -> { + QuestionnaireNavigationViewUIState.Enabled() + } questionnairePagination.isPaginated && questionnairePagination.hasNextPage -> { - QuestionnaireNavigationViewUIState.Enabled { goToNextPage() } + QuestionnaireNavigationViewUIState.Enabled( + labelStringRes = R.string.button_pagination_next, + onClickAction = { goToNextPage() }, + ) } else -> { QuestionnaireNavigationViewUIState.Hidden @@ -822,24 +859,39 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat navSubmit = if (showSubmitButton) { QuestionnaireNavigationViewUIState.Enabled( - submitButtonText, - onSubmitButtonClickListener, + labelText = submitButtonText, + onClickAction = onSubmitButtonClickListener, ) } else { QuestionnaireNavigationViewUIState.Hidden }, navReview = if (showReviewButton) { - QuestionnaireNavigationViewUIState.Enabled { setReviewMode(true) } + QuestionnaireNavigationViewUIState.Enabled( + labelStringRes = R.string.button_review, + onClickAction = { setReviewMode(true) }, + ) } else { QuestionnaireNavigationViewUIState.Hidden }, navCancel = if (showCancelButton) { - QuestionnaireNavigationViewUIState.Enabled(onClickAction = onCancelButtonClickListener) + QuestionnaireNavigationViewUIState.Enabled( + labelStringRes = R.string.cancel_questionnaire, + onClickAction = onCancelButtonClickListener, + ) } else { QuestionnaireNavigationViewUIState.Hidden }, + navNextProgressBar = + when { + questionnairePagination.isPaginated && isLoadingNextPage.value -> { + QuestionnaireNavigationViewUIState.Enabled() + } + else -> { + QuestionnaireNavigationViewUIState.Hidden + } + }, ) val bottomNavigation = QuestionnaireAdapterItem.Navigation(bottomNavigationUiViewState) @@ -1088,7 +1140,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat * Gets a list of [QuestionnairePage]s for a paginated questionnaire, or `null` if the * questionnaire is not paginated. */ - private suspend fun getQuestionnairePages(): List? = + internal suspend fun getQuestionnairePages(): List? = if (questionnaire.isPaginated) { questionnaire.item.zip(questionnaireResponse.item).mapIndexed { index, 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..94c2e56738 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 @@ -704,6 +704,10 @@ internal val QuestionnaireItemComponent.calculatedExpression: Expression? it.castToExpression(it.value) } +/** Returns list of extensions whose value is of type [Expression] */ +internal val Questionnaire.expressionBasedExtensions + get() = this.extension.filter { it.value is Expression } + /** Returns list of extensions whose value is of type [Expression] */ internal val QuestionnaireItemComponent.expressionBasedExtensions get() = this.extension.filter { it.value is Expression } @@ -713,7 +717,7 @@ internal val QuestionnaireItemComponent.expressionBasedExtensions * (e.g. if [item] has an expression `%resource.item.where(linkId='this-question')` where * `this-question` is the link ID of the current questionnaire item). */ -internal fun QuestionnaireItemComponent.isReferencedBy( +internal fun Questionnaire.QuestionnaireItemComponent.isExpressionReferencedBy( item: QuestionnaireItemComponent, ) = item.expressionBasedExtensions.any { @@ -724,6 +728,26 @@ internal fun QuestionnaireItemComponent.isReferencedBy( .contains(Regex(".*linkId='${this.linkId}'.*")) } +internal fun Questionnaire.QuestionnaireItemComponent.isExpressionReferencedBy( + questionnaire: Questionnaire, +) = + questionnaire.expressionBasedExtensions.any { + it + .castToExpression(it.value) + .expression + .replace(" ", "") + .contains(Regex(".*linkId='${this.linkId}'.*")) + } + +/** + * Whether [item] has any expression directly referencing the current questionnaire item by link ID + * (e.g. if [item] has an expression `%resource.item.where(linkId='this-question')` where + * `this-question` is the link ID of the current questionnaire item). + */ +internal fun Questionnaire.QuestionnaireItemComponent.isEnableWhenReferencedBy( + item: Questionnaire.QuestionnaireItemComponent, +) = item.enableWhen.any { it.question == this.linkId } + internal val QuestionnaireItemComponent.answerExpression: Expression? get() = ToolingExtensions.getExtension(this, EXTENSION_ANSWER_EXPRESSION_URL)?.value?.let { diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt index 97e74233e6..3c5f27c125 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt @@ -20,8 +20,8 @@ import com.google.android.fhir.datacapture.XFhirQueryResolver import com.google.android.fhir.datacapture.extensions.calculatedExpression import com.google.android.fhir.datacapture.extensions.findVariableExpression import com.google.android.fhir.datacapture.extensions.flattened +import com.google.android.fhir.datacapture.extensions.isExpressionReferencedBy import com.google.android.fhir.datacapture.extensions.isFhirPath -import com.google.android.fhir.datacapture.extensions.isReferencedBy import com.google.android.fhir.datacapture.extensions.isXFhirQuery import com.google.android.fhir.datacapture.extensions.variableExpressions import org.hl7.fhir.exceptions.FHIRException @@ -133,7 +133,10 @@ internal class ExpressionEvaluator( // no calculable item depending on current item should be used as dependency into current // item this.forEach { dependent -> - check(!(current.isReferencedBy(dependent) && dependent.isReferencedBy(current))) { + check( + !(current.isExpressionReferencedBy(dependent) && + dependent.isExpressionReferencedBy(current)), + ) { "${current.linkId} and ${dependent.linkId} have cyclic dependency in expression based extension" } } @@ -197,7 +200,7 @@ internal class ExpressionEvaluator( // Condition 1. item is calculable // Condition 2. item answer depends on the updated item answer OR has a variable dependency item.calculatedExpression != null && - (questionnaireItem.isReferencedBy(item) || + (questionnaireItem.isExpressionReferencedBy(item) || findDependentVariables(item.calculatedExpression!!).isNotEmpty()) } .map { item -> diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/NavigationViewHolder.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/NavigationViewHolder.kt index 171990ba05..d2e8f74386 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/NavigationViewHolder.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/NavigationViewHolder.kt @@ -22,6 +22,7 @@ import androidx.recyclerview.widget.RecyclerView import com.google.android.fhir.datacapture.QuestionnaireNavigationUIState import com.google.android.fhir.datacapture.QuestionnaireNavigationViewUIState import com.google.android.fhir.datacapture.R +import com.google.android.material.progressindicator.CircularProgressIndicator class NavigationViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { @@ -41,6 +42,9 @@ class NavigationViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { itemView .findViewById