Skip to content

Commit 4e3050b

Browse files
committed
Migrate RadioGroupViewHolderFactory to compose
1 parent b832190 commit 4e3050b

File tree

7 files changed

+769
-810
lines changed

7 files changed

+769
-810
lines changed

datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/QuestionnaireUiEspressoTest.kt

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,20 +95,28 @@ class QuestionnaireUiEspressoTest {
9595
fun shouldDisplayReviewButtonWhenNoMorePagesToDisplay() {
9696
buildFragmentFromQuestionnaire("/paginated_questionnaire_with_dependent_answer.json", true)
9797

98+
// synchronize
99+
composeTestRule.waitForIdle()
98100
onView(withId(com.google.android.fhir.datacapture.R.id.review_mode_button))
99101
.check(
100102
ViewAssertions.matches(
101103
ViewMatchers.withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE),
102104
),
103105
)
104106

105-
clickOnText("Yes")
107+
composeTestRule.onNodeWithText("Yes").performClick()
108+
109+
// synchronize
110+
composeTestRule.waitForIdle()
106111
onView(withId(com.google.android.fhir.datacapture.R.id.review_mode_button))
107112
.check(
108113
ViewAssertions.matches(ViewMatchers.withEffectiveVisibility(ViewMatchers.Visibility.GONE)),
109114
)
110115

111-
clickOnText("No")
116+
composeTestRule.onNodeWithText("No").performClick()
117+
118+
// synchronize
119+
composeTestRule.waitForIdle()
112120
onView(withId(com.google.android.fhir.datacapture.R.id.review_mode_button))
113121
.check(
114122
ViewAssertions.matches(
@@ -542,7 +550,7 @@ class QuestionnaireUiEspressoTest {
542550
assertThat(view).isNull()
543551
}
544552

545-
onView(CoreMatchers.allOf(withText("First Option"))).perform(ViewActions.click())
553+
composeTestRule.onNodeWithText("First Option").performClick()
546554

547555
onView(CoreMatchers.allOf(withText("Option Date"))).check { view, _ ->
548556
assertThat(view).isNull()

datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/RadioGroupViewHolderFactoryTest.kt

Lines changed: 627 additions & 0 deletions
Large diffs are not rendered by default.

datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/ChoiceRadioButton.kt

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,32 +16,40 @@
1616

1717
package com.google.android.fhir.datacapture.views.compose
1818

19+
import android.graphics.drawable.Drawable
1920
import androidx.compose.foundation.background
2021
import androidx.compose.foundation.border
2122
import androidx.compose.foundation.layout.Row
2223
import androidx.compose.foundation.layout.Spacer
2324
import androidx.compose.foundation.layout.padding
25+
import androidx.compose.foundation.layout.size
2426
import androidx.compose.foundation.layout.width
2527
import androidx.compose.foundation.selection.selectable
2628
import androidx.compose.foundation.shape.RoundedCornerShape
29+
import androidx.compose.material3.Icon
2730
import androidx.compose.material3.MaterialTheme
2831
import androidx.compose.material3.RadioButton
2932
import androidx.compose.material3.Text
3033
import androidx.compose.runtime.Composable
3134
import androidx.compose.ui.Alignment
3235
import androidx.compose.ui.Modifier
3336
import androidx.compose.ui.draw.clip
37+
import androidx.compose.ui.graphics.asImageBitmap
38+
import androidx.compose.ui.platform.testTag
3439
import androidx.compose.ui.res.dimensionResource
3540
import androidx.compose.ui.semantics.Role
41+
import androidx.compose.ui.text.AnnotatedString
3642
import androidx.compose.ui.unit.dp
43+
import androidx.core.graphics.drawable.toBitmap
3744
import com.google.android.fhir.datacapture.R
3845

3946
@Composable
4047
internal fun ChoiceRadioButton(
41-
label: String,
48+
label: AnnotatedString,
4249
selected: Boolean,
4350
enabled: Boolean,
4451
modifier: Modifier = Modifier,
52+
image: Drawable? = null,
4553
onClick: () -> Unit,
4654
) {
4755
val backgroundColor =
@@ -97,15 +105,25 @@ internal fun ChoiceRadioButton(
97105
onClick = null,
98106
enabled = enabled,
99107
)
108+
// Display image
109+
image?.let { drawable ->
110+
Spacer(modifier = Modifier.width(8.dp))
111+
Icon(
112+
bitmap = drawable.toBitmap().asImageBitmap(),
113+
contentDescription = null,
114+
modifier = Modifier.testTag(CHOICE_RADIO_BUTTON_IMAGE_TAG).size(24.dp),
115+
)
116+
}
100117
Spacer(
101118
modifier =
102119
Modifier.width(dimensionResource(R.dimen.option_item_between_text_and_icon_padding)),
103120
)
104121
Text(
105122
text = label,
106123
color = textColor,
107-
modifier = Modifier.padding(start = dimensionResource(R.dimen.item_margin_horizontal)),
108124
)
109125
Spacer(modifier = Modifier.width(dimensionResource(R.dimen.option_item_after_text_padding)))
110126
}
111127
}
128+
129+
const val CHOICE_RADIO_BUTTON_IMAGE_TAG = "radio_button_option_icon"

datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/BooleanChoiceViewHolderFactory.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import androidx.compose.ui.Modifier
3232
import androidx.compose.ui.platform.testTag
3333
import androidx.compose.ui.res.dimensionResource
3434
import androidx.compose.ui.res.stringResource
35+
import androidx.compose.ui.text.AnnotatedString
3536
import com.google.android.fhir.datacapture.R
3637
import com.google.android.fhir.datacapture.extensions.ChoiceOrientationTypes
3738
import com.google.android.fhir.datacapture.extensions.choiceOrientation
@@ -68,7 +69,7 @@ internal object BooleanChoiceViewHolderFactory : QuestionnaireItemComposeViewHol
6869
@Suppress("LocalVariableName")
6970
val YesChoiceRadioButton: @Composable (Modifier) -> Unit = {
7071
ChoiceRadioButton(
71-
label = stringResource(R.string.yes),
72+
label = AnnotatedString(stringResource(R.string.yes)),
7273
selected = selectedChoiceState == true,
7374
enabled = !readOnly,
7475
modifier = it.testTag(YES_CHOICE_RADIO_BUTTON_TAG),
@@ -92,7 +93,7 @@ internal object BooleanChoiceViewHolderFactory : QuestionnaireItemComposeViewHol
9293
@Suppress("LocalVariableName")
9394
val NoChoiceRadioButton: @Composable (Modifier) -> Unit = {
9495
ChoiceRadioButton(
95-
label = stringResource(R.string.no),
96+
label = AnnotatedString(stringResource(R.string.no)),
9697
selected = selectedChoiceState == false,
9798
enabled = !readOnly,
9899
modifier = it.testTag(NO_CHOICE_RADIO_BUTTON_TAG),
@@ -135,6 +136,8 @@ internal object BooleanChoiceViewHolderFactory : QuestionnaireItemComposeViewHol
135136
modifier = Modifier.selectableGroup().fillMaxWidth(),
136137
horizontalArrangement =
137138
Arrangement.spacedBy(dimensionResource(R.dimen.option_item_margin_horizontal)),
139+
verticalArrangement =
140+
Arrangement.spacedBy(dimensionResource(R.dimen.option_item_margin_vertical)),
138141
) {
139142
YesChoiceRadioButton(Modifier.weight(1f))
140143
NoChoiceRadioButton(Modifier.weight(1f))

datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/RadioGroupViewHolderFactory.kt

Lines changed: 106 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -16,140 +16,131 @@
1616

1717
package com.google.android.fhir.datacapture.views.factories
1818

19-
import android.view.LayoutInflater
20-
import android.view.View
21-
import android.view.ViewGroup
22-
import android.widget.RadioButton
23-
import androidx.appcompat.app.AppCompatActivity
24-
import androidx.constraintlayout.helper.widget.Flow
25-
import androidx.constraintlayout.widget.ConstraintLayout
26-
import androidx.core.view.children
27-
import androidx.lifecycle.lifecycleScope
19+
import androidx.compose.foundation.layout.Arrangement
20+
import androidx.compose.foundation.layout.Column
21+
import androidx.compose.foundation.layout.FlowRow
22+
import androidx.compose.foundation.layout.fillMaxWidth
23+
import androidx.compose.foundation.layout.padding
24+
import androidx.compose.foundation.selection.selectableGroup
25+
import androidx.compose.runtime.Composable
26+
import androidx.compose.runtime.getValue
27+
import androidx.compose.runtime.mutableStateOf
28+
import androidx.compose.runtime.remember
29+
import androidx.compose.runtime.rememberCoroutineScope
30+
import androidx.compose.runtime.setValue
31+
import androidx.compose.ui.Modifier
32+
import androidx.compose.ui.platform.LocalContext
33+
import androidx.compose.ui.platform.testTag
34+
import androidx.compose.ui.res.dimensionResource
35+
import androidx.compose.ui.text.AnnotatedString
2836
import com.google.android.fhir.datacapture.R
2937
import com.google.android.fhir.datacapture.extensions.ChoiceOrientationTypes
3038
import com.google.android.fhir.datacapture.extensions.choiceOrientation
31-
import com.google.android.fhir.datacapture.extensions.displayStringSpanned
39+
import com.google.android.fhir.datacapture.extensions.displayString
3240
import com.google.android.fhir.datacapture.extensions.itemAnswerOptionImage
33-
import com.google.android.fhir.datacapture.extensions.tryUnwrapContext
34-
import com.google.android.fhir.datacapture.views.HeaderView
41+
import com.google.android.fhir.datacapture.extensions.itemMedia
3542
import com.google.android.fhir.datacapture.views.QuestionnaireViewItem
43+
import com.google.android.fhir.datacapture.views.compose.ChoiceRadioButton
44+
import com.google.android.fhir.datacapture.views.compose.Header
45+
import com.google.android.fhir.datacapture.views.compose.MediaItem
46+
import kotlinx.coroutines.Dispatchers
3647
import kotlinx.coroutines.launch
37-
import org.hl7.fhir.r4.model.Questionnaire
3848
import org.hl7.fhir.r4.model.QuestionnaireResponse
3949

40-
internal object RadioGroupViewHolderFactory :
41-
QuestionnaireItemAndroidViewHolderFactory(R.layout.radio_group_view) {
50+
internal object RadioGroupViewHolderFactory : QuestionnaireItemComposeViewHolderFactory {
4251
override fun getQuestionnaireItemViewHolderDelegate() =
43-
object : QuestionnaireItemAndroidViewHolderDelegate {
44-
private lateinit var appContext: AppCompatActivity
45-
private lateinit var header: HeaderView
46-
private lateinit var radioGroup: ConstraintLayout
47-
private lateinit var flow: Flow
48-
override lateinit var questionnaireViewItem: QuestionnaireViewItem
52+
object : QuestionnaireItemComposeViewHolderDelegate {
4953

50-
override fun init(itemView: View) {
51-
appContext = itemView.context.tryUnwrapContext()!!
52-
header = itemView.findViewById(R.id.header)
53-
radioGroup = itemView.findViewById(R.id.radio_group)
54-
flow = itemView.findViewById(R.id.flow)
55-
}
56-
57-
override fun bind(questionnaireViewItem: QuestionnaireViewItem) {
58-
header.bind(
59-
questionnaireViewItem,
60-
showRequiredOrOptionalText = true,
61-
displayValidationResult = true,
62-
)
63-
// Keep the Flow layout which is the first child
64-
radioGroup.removeViews(1, radioGroup.childCount - 1)
65-
val choiceOrientation =
66-
questionnaireViewItem.questionnaireItem.choiceOrientation
67-
?: ChoiceOrientationTypes.VERTICAL
68-
when (choiceOrientation) {
69-
ChoiceOrientationTypes.HORIZONTAL -> {
70-
flow.setOrientation(Flow.HORIZONTAL)
71-
flow.setWrapMode(Flow.WRAP_CHAIN)
54+
@Composable
55+
override fun Content(questionnaireViewItem: QuestionnaireViewItem) {
56+
val context = LocalContext.current
57+
val coroutineScope = rememberCoroutineScope { Dispatchers.Main }
58+
val readOnly =
59+
remember(questionnaireViewItem) { questionnaireViewItem.questionnaireItem.readOnly }
60+
val choiceOrientationType =
61+
remember(questionnaireViewItem) {
62+
questionnaireViewItem.questionnaireItem.choiceOrientation
63+
?: ChoiceOrientationTypes.VERTICAL
7264
}
73-
ChoiceOrientationTypes.VERTICAL -> {
74-
flow.setOrientation(Flow.VERTICAL)
75-
flow.setWrapMode(Flow.WRAP_NONE)
65+
val enabledAnswerOptions =
66+
remember(questionnaireViewItem) { questionnaireViewItem.enabledAnswerOptions }
67+
var selectedAnswerOption by
68+
remember(questionnaireViewItem) {
69+
mutableStateOf(
70+
enabledAnswerOptions.singleOrNull {
71+
questionnaireViewItem.isAnswerOptionSelected(it)
72+
},
73+
)
7674
}
77-
}
78-
questionnaireViewItem.enabledAnswerOptions
79-
.map { answerOption -> View.generateViewId() to answerOption }
80-
.onEach { populateViewWithAnswerOption(it.first, it.second, choiceOrientation) }
81-
.map { it.first }
82-
.let { flow.referencedIds = it.toIntArray() }
83-
}
8475

85-
override fun setReadOnly(isReadOnly: Boolean) {
86-
// The Flow layout has index 0. The radio button indices start from 1.
87-
for (i in 1 until radioGroup.childCount) {
88-
val view = radioGroup.getChildAt(i)
89-
view.isEnabled = !isReadOnly
90-
}
91-
}
92-
93-
private fun populateViewWithAnswerOption(
94-
viewId: Int,
95-
answerOption: Questionnaire.QuestionnaireItemAnswerOptionComponent,
96-
choiceOrientation: ChoiceOrientationTypes,
97-
) {
98-
val radioButtonItem =
99-
LayoutInflater.from(radioGroup.context).inflate(R.layout.radio_button, null)
100-
var isCurrentlySelected = questionnaireViewItem.isAnswerOptionSelected(answerOption)
101-
val radioButton =
102-
radioButtonItem.findViewById<RadioButton>(R.id.radio_button).apply {
103-
id = viewId
104-
text = answerOption.value.displayStringSpanned(header.context)
105-
setCompoundDrawablesRelative(
106-
answerOption.itemAnswerOptionImage(radioGroup.context),
107-
null,
108-
null,
109-
null,
110-
)
111-
layoutParams =
112-
ViewGroup.LayoutParams(
113-
when (choiceOrientation) {
114-
ChoiceOrientationTypes.HORIZONTAL -> ViewGroup.LayoutParams.WRAP_CONTENT
115-
ChoiceOrientationTypes.VERTICAL -> ViewGroup.LayoutParams.MATCH_PARENT
116-
},
117-
ViewGroup.LayoutParams.WRAP_CONTENT,
118-
)
119-
isChecked = isCurrentlySelected
120-
setOnClickListener { radioButton ->
121-
appContext.lifecycleScope.launch {
122-
isCurrentlySelected = !isCurrentlySelected
123-
when (isCurrentlySelected) {
124-
true -> {
125-
updateAnswer(answerOption)
126-
val buttons = radioGroup.children.asIterable().filterIsInstance<RadioButton>()
127-
buttons.forEach { button -> uncheckIfNotButtonId(radioButton.id, button) }
128-
}
129-
false -> {
130-
questionnaireViewItem.clearAnswer()
131-
(radioButton as RadioButton).isChecked = false
132-
}
76+
@Suppress("LocalVariableName")
77+
val AnswerOptionRadioButtons: @Composable (Modifier) -> Unit = { modifier ->
78+
enabledAnswerOptions.forEach {
79+
val labelText = remember(it) { AnnotatedString(it.value.displayString(context)) }
80+
ChoiceRadioButton(
81+
label = labelText,
82+
selected = it == selectedAnswerOption,
83+
enabled = !readOnly,
84+
modifier = modifier.testTag(RADIO_OPTION_TAG),
85+
image = it.itemAnswerOptionImage(context),
86+
) {
87+
coroutineScope.launch {
88+
if (selectedAnswerOption != it) {
89+
selectedAnswerOption = it
90+
questionnaireViewItem.setAnswer(
91+
QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply {
92+
value = it.value
93+
},
94+
)
95+
} else {
96+
selectedAnswerOption = null
97+
questionnaireViewItem.clearAnswer()
13398
}
13499
}
135100
}
136101
}
137-
radioGroup.addView(radioButton)
138-
flow.addView(radioButton)
139-
}
102+
}
140103

141-
private fun uncheckIfNotButtonId(checkedId: Int, button: RadioButton) {
142-
if (button.id != checkedId) button.isChecked = false
143-
}
104+
Column(
105+
modifier =
106+
Modifier.fillMaxWidth()
107+
.padding(
108+
horizontal = dimensionResource(R.dimen.item_margin_horizontal),
109+
vertical = dimensionResource(R.dimen.item_margin_vertical),
110+
),
111+
) {
112+
Header(
113+
questionnaireViewItem,
114+
showRequiredOrOptionalText = true,
115+
displayValidationResult = true,
116+
)
117+
questionnaireViewItem.questionnaireItem.itemMedia?.let { MediaItem(it) }
144118

145-
private suspend fun updateAnswer(
146-
answerOption: Questionnaire.QuestionnaireItemAnswerOptionComponent,
147-
) {
148-
questionnaireViewItem.setAnswer(
149-
QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply {
150-
value = answerOption.value
151-
},
152-
)
119+
when (choiceOrientationType) {
120+
ChoiceOrientationTypes.HORIZONTAL -> {
121+
FlowRow(
122+
modifier = Modifier.selectableGroup().fillMaxWidth(),
123+
horizontalArrangement =
124+
Arrangement.spacedBy(dimensionResource(R.dimen.option_item_margin_horizontal)),
125+
verticalArrangement =
126+
Arrangement.spacedBy(dimensionResource(R.dimen.option_item_margin_vertical)),
127+
) {
128+
AnswerOptionRadioButtons(Modifier.weight(1f))
129+
}
130+
}
131+
ChoiceOrientationTypes.VERTICAL -> {
132+
Column(
133+
modifier = Modifier.selectableGroup().fillMaxWidth(),
134+
verticalArrangement =
135+
Arrangement.spacedBy(dimensionResource(R.dimen.option_item_margin_vertical)),
136+
) {
137+
AnswerOptionRadioButtons(Modifier.fillMaxWidth())
138+
}
139+
}
140+
}
141+
}
153142
}
154143
}
155144
}
145+
146+
const val RADIO_OPTION_TAG = "radio_group_option"

0 commit comments

Comments
 (0)