Merge pull request #7613 from element-hq/feature/bma/polls-max-selections

Support creating polls with multiple answers and send response with multiple answers
This commit is contained in:
Benoit Marty
2026-09-01 09:50:13 +02:00
committed by GitHub
49 changed files with 276 additions and 79 deletions
@@ -52,9 +52,9 @@ sealed interface TimelineEvent {
*/ */
sealed interface TimelineItemPollEvent : TimelineItemEvent sealed interface TimelineItemPollEvent : TimelineItemEvent
data class SelectPollAnswer( data class SendPollResponse(
val pollStartId: EventId, val pollStartId: EventId,
val answerId: String val answerIds: List<String>
) : TimelineItemPollEvent ) : TimelineItemPollEvent
data class EndPoll( data class EndPoll(
@@ -201,13 +201,15 @@ class TimelinePresenter(
newEventState.value = NewEventState.None newEventState.value = NewEventState.None
} }
} }
is TimelineEvent.SelectPollAnswer -> sessionCoroutineScope.launch { is TimelineEvent.SendPollResponse -> sessionCoroutineScope.launch {
timelineController.invokeOnCurrentTimeline { timelineController.invokeOnCurrentTimeline {
sendPollResponseAction.execute( sendPollResponseAction.execute(
timeline = this, timeline = this,
pollStartId = event.pollStartId, pollStartId = event.pollStartId,
answerId = event.answerId answerIds = event.answerIds,
) ).onFailure { error ->
Timber.tag(tag).e(error, "Failed to send poll response")
}
} }
} }
is TimelineEvent.EndPoll -> sessionCoroutineScope.launch { is TimelineEvent.EndPoll -> sessionCoroutineScope.launch {
@@ -26,8 +26,8 @@ fun TimelineItemPollView(
eventSink: (TimelineEvent.TimelineItemPollEvent) -> Unit, eventSink: (TimelineEvent.TimelineItemPollEvent) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
fun onSelectAnswer(pollStartId: EventId, answerId: String) { fun onSendPollResponse(pollStartId: EventId, answerIds: List<String>) {
eventSink(TimelineEvent.SelectPollAnswer(pollStartId, answerId)) eventSink(TimelineEvent.SendPollResponse(pollStartId, answerIds))
} }
fun onEndPoll(pollStartId: EventId) { fun onEndPoll(pollStartId: EventId) {
@@ -43,10 +43,12 @@ fun TimelineItemPollView(
question = content.question, question = content.question,
answerItems = content.answerItems.toImmutableList(), answerItems = content.answerItems.toImmutableList(),
pollKind = content.pollKind, pollKind = content.pollKind,
isMultipleSelection = content.maxSelections > 1u,
maxSelections = content.maxSelections,
isPollEnded = content.isEnded, isPollEnded = content.isEnded,
isPollEditable = content.isEditable, isPollEditable = content.isEditable,
isMine = content.isMine, isMine = content.isMine,
onSelectAnswer = ::onSelectAnswer, onSendPollResponse = ::onSendPollResponse,
onEditPoll = ::onEditPoll, onEditPoll = ::onEditPoll,
onEndPoll = ::onEndPoll, onEndPoll = ::onEndPoll,
modifier = modifier, modifier = modifier,
@@ -33,6 +33,7 @@ class TimelineItemContentPollFactory(
question = pollContentState.question, question = pollContentState.question,
answerItems = pollContentState.answerItems, answerItems = pollContentState.answerItems,
pollKind = pollContentState.pollKind, pollKind = pollContentState.pollKind,
maxSelections = pollContentState.maxSelections,
isEnded = pollContentState.isPollEnded, isEnded = pollContentState.isPollEnded,
isEdited = content.isEdited isEdited = content.isEdited
) )
@@ -19,6 +19,7 @@ data class TimelineItemPollContent(
val question: String, val question: String,
val answerItems: List<PollAnswerItem>, val answerItems: List<PollAnswerItem>,
val pollKind: PollKind, val pollKind: PollKind,
val maxSelections: ULong,
val isEnded: Boolean, val isEnded: Boolean,
override val isEdited: Boolean, override val isEdited: Boolean,
) : TimelineItemEventContent, ) : TimelineItemEventContent,
@@ -32,12 +32,14 @@ fun aTimelineItemPollContent(
isEditable: Boolean = false, isEditable: Boolean = false,
isEnded: Boolean = false, isEnded: Boolean = false,
isEdited: Boolean = false, isEdited: Boolean = false,
maxSelections: ULong = 1u,
): TimelineItemPollContent { ): TimelineItemPollContent {
return TimelineItemPollContent( return TimelineItemPollContent(
eventId = EventId("\$anEventId"), eventId = EventId("\$anEventId"),
pollKind = PollKind.Disclosed, pollKind = PollKind.Disclosed,
question = question, question = question,
answerItems = answerItems, answerItems = answerItems,
maxSelections = maxSelections,
isMine = isMine, isMine = isMine,
isEditable = isEditable, isEditable = isEditable,
isEnded = isEnded, isEnded = isEnded,
@@ -951,14 +951,14 @@ class TimelinePresenterTest {
} }
@Test @Test
fun `present - PollAnswerSelected event`() = runTest { fun `present - SendPollResponse event`() = runTest {
val sendPollResponseAction = FakeSendPollResponseAction() val sendPollResponseAction = FakeSendPollResponseAction()
val presenter = createTimelinePresenter( val presenter = createTimelinePresenter(
sendPollResponseAction = sendPollResponseAction, sendPollResponseAction = sendPollResponseAction,
) )
presenter.test { presenter.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvent.SelectPollAnswer(AN_EVENT_ID, "anAnswerId")) initialState.eventSink.invoke(TimelineEvent.SendPollResponse(AN_EVENT_ID, listOf("anAnswerId")))
} }
delay(1) delay(1)
sendPollResponseAction.verifyExecutionCount(1) sendPollResponseAction.verifyExecutionCount(1)
@@ -50,7 +50,7 @@ class TimelineItemPollViewTest : RobolectricTest() {
matcher = hasText(answer.text), matcher = hasText(answer.text),
useUnmergedTree = true, useUnmergedTree = true,
).performClick() ).performClick()
eventsRecorder.assertSingle(TimelineEvent.SelectPollAnswer(content.eventId!!, answer.id)) eventsRecorder.assertSingle(TimelineEvent.SendPollResponse(content.eventId!!, listOf(answer.id)))
} }
@Test @Test
@@ -12,17 +12,17 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
/** /**
* Sends the current user's answer to a poll, wrapping the timeline call so the analytics are captured in one place. * Sends the current user's answer(s) to a poll, wrapping the timeline call so the analytics are captured in one place.
*/ */
interface SendPollResponseAction { interface SendPollResponseAction {
/** /**
* @param timeline the timeline the poll lives in. * @param timeline the timeline the poll lives in.
* @param pollStartId the poll start event being answered. * @param pollStartId the poll start event being answered.
* @param answerId the id of the chosen answer. * @param answerIds the ids of the chosen answers.
*/ */
suspend fun execute( suspend fun execute(
timeline: Timeline, timeline: Timeline,
pollStartId: EventId, pollStartId: EventId,
answerId: String answerIds: List<String>,
): Result<Unit> ): Result<Unit>
} }
@@ -18,6 +18,7 @@ import kotlinx.collections.immutable.ImmutableList
* @property question the poll question. * @property question the poll question.
* @property answerItems the list of answers. * @property answerItems the list of answers.
* @property pollKind the kind of poll. * @property pollKind the kind of poll.
* @property maxSelections the maximum number of selections allowed.
* @property isPollEditable whether the poll is editable. * @property isPollEditable whether the poll is editable.
* @property isPollEnded whether the poll is ended. * @property isPollEnded whether the poll is ended.
* @property isMine whether the poll has been created by me. * @property isMine whether the poll has been created by me.
@@ -27,7 +28,10 @@ data class PollContentState(
val question: String, val question: String,
val answerItems: ImmutableList<PollAnswerItem>, val answerItems: ImmutableList<PollAnswerItem>,
val pollKind: PollKind, val pollKind: PollKind,
val maxSelections: ULong,
val isPollEditable: Boolean, val isPollEditable: Boolean,
val isPollEnded: Boolean, val isPollEnded: Boolean,
val isMine: Boolean, val isMine: Boolean,
) ) {
val isMultipleSelection = maxSelections > 1u
}
@@ -84,6 +84,7 @@ fun aPollContentState(
hasVotes: Boolean = true, hasVotes: Boolean = true,
question: String = aPollQuestion(), question: String = aPollQuestion(),
pollKind: PollKind = PollKind.Disclosed, pollKind: PollKind = PollKind.Disclosed,
maxSelections: ULong = 1u,
answerItems: ImmutableList<PollAnswerItem> = aPollAnswerItemList( answerItems: ImmutableList<PollAnswerItem> = aPollAnswerItemList(
isEnded = isEnded, isEnded = isEnded,
showVotes = showVotes, showVotes = showVotes,
@@ -94,6 +95,7 @@ fun aPollContentState(
question = question, question = question,
answerItems = answerItems, answerItems = answerItems,
pollKind = pollKind, pollKind = pollKind,
maxSelections = maxSelections,
isPollEditable = isMine && !isEnded && isPollEditable, isPollEditable = isMine && !isEnded && isPollEditable,
isPollEnded = isEnded, isPollEnded = isEnded,
isMine = isMine, isMine = isMine,
@@ -40,7 +40,7 @@ import kotlinx.collections.immutable.ImmutableList
@Composable @Composable
fun PollContentView( fun PollContentView(
state: PollContentState, state: PollContentState,
onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit, onSendPollResponse: (pollStartId: EventId, answerIds: List<String>) -> Unit,
onEditPoll: (pollStartId: EventId) -> Unit, onEditPoll: (pollStartId: EventId) -> Unit,
onEndPoll: (pollStartId: EventId) -> Unit, onEndPoll: (pollStartId: EventId) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -50,11 +50,13 @@ fun PollContentView(
question = state.question, question = state.question,
answerItems = state.answerItems, answerItems = state.answerItems,
pollKind = state.pollKind, pollKind = state.pollKind,
isMultipleSelection = state.isMultipleSelection,
maxSelections = state.maxSelections,
isPollEditable = state.isPollEditable, isPollEditable = state.isPollEditable,
isPollEnded = state.isPollEnded, isPollEnded = state.isPollEnded,
isMine = state.isMine, isMine = state.isMine,
onEditPoll = onEditPoll, onEditPoll = onEditPoll,
onSelectAnswer = onSelectAnswer, onSendPollResponse = onSendPollResponse,
onEndPoll = onEndPoll, onEndPoll = onEndPoll,
modifier = modifier, modifier = modifier,
) )
@@ -66,10 +68,12 @@ fun PollContentView(
question: String, question: String,
answerItems: ImmutableList<PollAnswerItem>, answerItems: ImmutableList<PollAnswerItem>,
pollKind: PollKind, pollKind: PollKind,
isMultipleSelection: Boolean,
maxSelections: ULong,
isPollEditable: Boolean, isPollEditable: Boolean,
isPollEnded: Boolean, isPollEnded: Boolean,
isMine: Boolean, isMine: Boolean,
onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit, onSendPollResponse: (pollStartId: EventId, answerIds: List<String>) -> Unit,
onEditPoll: (pollStartId: EventId) -> Unit, onEditPoll: (pollStartId: EventId) -> Unit,
onEndPoll: (pollStartId: EventId) -> Unit, onEndPoll: (pollStartId: EventId) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -77,7 +81,23 @@ fun PollContentView(
val votesCount = remember(answerItems) { answerItems.sumOf { it.votesCount } } val votesCount = remember(answerItems) { answerItems.sumOf { it.votesCount } }
fun onSelectAnswer(pollAnswer: PollAnswer) { fun onSelectAnswer(pollAnswer: PollAnswer) {
eventId?.let { onSelectAnswer(it, pollAnswer.id) } eventId?.let {
if (isMultipleSelection) {
val newSelections = answerItems.map { item ->
if (item.answer.id == pollAnswer.id) {
item.copy(isSelected = !item.isSelected)
} else {
item
}
}.filter { it.isSelected }.map { it.answer.id }
// Only send the response if the selection count is within the limit
if (newSelections.size <= maxSelections.toInt()) {
onSendPollResponse(it, newSelections)
}
} else {
onSendPollResponse(it, listOf(pollAnswer.id))
}
}
} }
fun onEditPoll() { fun onEditPoll() {
@@ -107,7 +127,11 @@ fun PollContentView(
) { ) {
PollTitleView(title = question, isPollEnded = isPollEnded) PollTitleView(title = question, isPollEnded = isPollEnded)
PollAnswers(answerItems = answerItems, onSelectAnswer = ::onSelectAnswer) PollAnswers(
answerItems = answerItems,
isMultipleSelection = isMultipleSelection,
onSelectAnswer = ::onSelectAnswer,
)
if (isPollEnded || pollKind == PollKind.Disclosed) { if (isPollEnded || pollKind == PollKind.Disclosed) {
DisclosedPollBottomNotice(votesCount = votesCount) DisclosedPollBottomNotice(votesCount = votesCount)
@@ -130,8 +154,10 @@ fun PollContentView(
@Composable @Composable
private fun PollAnswers( private fun PollAnswers(
answerItems: ImmutableList<PollAnswerItem>, answerItems: ImmutableList<PollAnswerItem>,
isMultipleSelection: Boolean,
onSelectAnswer: (PollAnswer) -> Unit, onSelectAnswer: (PollAnswer) -> Unit,
) { ) {
val role = if (isMultipleSelection) Role.Checkbox else Role.RadioButton
Column( Column(
modifier = Modifier.selectableGroup(), modifier = Modifier.selectableGroup(),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
@@ -144,7 +170,7 @@ private fun PollAnswers(
selected = it.isSelected, selected = it.isSelected,
enabled = it.isEnabled, enabled = it.isEnabled,
onClick = { onSelectAnswer(it.answer) }, onClick = { onSelectAnswer(it.answer) },
role = Role.RadioButton, role = role,
), ),
) )
} }
@@ -207,10 +233,12 @@ internal fun PollContentViewUndisclosedPreview() = ElementPreview {
question = "What type of food should we have at the party?", question = "What type of food should we have at the party?",
answerItems = aPollAnswerItemList(showVotes = false), answerItems = aPollAnswerItemList(showVotes = false),
pollKind = PollKind.Undisclosed, pollKind = PollKind.Undisclosed,
isMultipleSelection = false,
maxSelections = 1u,
isPollEnded = false, isPollEnded = false,
isPollEditable = false, isPollEditable = false,
isMine = false, isMine = false,
onSelectAnswer = { _, _ -> }, onSendPollResponse = { _, _ -> },
onEditPoll = {}, onEditPoll = {},
onEndPoll = {}, onEndPoll = {},
) )
@@ -224,10 +252,31 @@ internal fun PollContentViewDisclosedPreview() = ElementPreview {
question = "What type of food should we have at the party?", question = "What type of food should we have at the party?",
answerItems = aPollAnswerItemList(), answerItems = aPollAnswerItemList(),
pollKind = PollKind.Disclosed, pollKind = PollKind.Disclosed,
isMultipleSelection = false,
maxSelections = 1u,
isPollEnded = false, isPollEnded = false,
isPollEditable = false, isPollEditable = false,
isMine = false, isMine = false,
onSelectAnswer = { _, _ -> }, onSendPollResponse = { _, _ -> },
onEditPoll = {},
onEndPoll = {},
)
}
@PreviewsDayNight
@Composable
internal fun PollContentViewMultipleSelectionPreview() = ElementPreview {
PollContentView(
eventId = EventId("\$anEventId"),
question = "What type of food should we have at the party?",
answerItems = aPollAnswerItemList(),
pollKind = PollKind.Disclosed,
isMultipleSelection = true,
maxSelections = 3u,
isPollEnded = false,
isPollEditable = false,
isMine = false,
onSendPollResponse = { _, _ -> },
onEditPoll = {}, onEditPoll = {},
onEndPoll = {}, onEndPoll = {},
) )
@@ -241,10 +290,12 @@ internal fun PollContentViewEndedPreview() = ElementPreview {
question = "What type of food should we have at the party?", question = "What type of food should we have at the party?",
answerItems = aPollAnswerItemList(isEnded = true), answerItems = aPollAnswerItemList(isEnded = true),
pollKind = PollKind.Disclosed, pollKind = PollKind.Disclosed,
isMultipleSelection = false,
maxSelections = 1u,
isPollEnded = true, isPollEnded = true,
isPollEditable = false, isPollEditable = false,
isMine = false, isMine = false,
onSelectAnswer = { _, _ -> }, onSendPollResponse = { _, _ -> },
onEditPoll = {}, onEditPoll = {},
onEndPoll = {}, onEndPoll = {},
) )
@@ -258,10 +309,12 @@ internal fun PollContentViewCreatorEditablePreview() = ElementPreview {
question = "What type of food should we have at the party?", question = "What type of food should we have at the party?",
answerItems = aPollAnswerItemList(hasVotes = false, isEnded = false), answerItems = aPollAnswerItemList(hasVotes = false, isEnded = false),
pollKind = PollKind.Disclosed, pollKind = PollKind.Disclosed,
isMultipleSelection = false,
maxSelections = 1u,
isPollEnded = false, isPollEnded = false,
isPollEditable = true, isPollEditable = true,
isMine = true, isMine = true,
onSelectAnswer = { _, _ -> }, onSendPollResponse = { _, _ -> },
onEditPoll = {}, onEditPoll = {},
onEndPoll = {}, onEndPoll = {},
) )
@@ -275,10 +328,12 @@ internal fun PollContentViewCreatorPreview() = ElementPreview {
question = "What type of food should we have at the party?", question = "What type of food should we have at the party?",
answerItems = aPollAnswerItemList(isEnded = false), answerItems = aPollAnswerItemList(isEnded = false),
pollKind = PollKind.Disclosed, pollKind = PollKind.Disclosed,
isMultipleSelection = false,
maxSelections = 1u,
isPollEnded = false, isPollEnded = false,
isPollEditable = false, isPollEditable = false,
isMine = true, isMine = true,
onSelectAnswer = { _, _ -> }, onSendPollResponse = { _, _ -> },
onEditPoll = {}, onEditPoll = {},
onEndPoll = {}, onEndPoll = {},
) )
@@ -292,10 +347,12 @@ internal fun PollContentViewCreatorEndedPreview() = ElementPreview {
question = "What type of food should we have at the party?", question = "What type of food should we have at the party?",
answerItems = aPollAnswerItemList(isEnded = true), answerItems = aPollAnswerItemList(isEnded = true),
pollKind = PollKind.Disclosed, pollKind = PollKind.Disclosed,
isMultipleSelection = false,
maxSelections = 1u,
isPollEnded = true, isPollEnded = true,
isPollEditable = false, isPollEditable = false,
isMine = true, isMine = true,
onSelectAnswer = { _, _ -> }, onSendPollResponse = { _, _ -> },
onEditPoll = {}, onEditPoll = {},
onEndPoll = {}, onEndPoll = {},
) )
@@ -12,5 +12,5 @@ internal object PollConstants {
const val MIN_ANSWERS = 2 const val MIN_ANSWERS = 2
const val MAX_ANSWERS = 20 const val MAX_ANSWERS = 20
const val MAX_ANSWER_LENGTH = 240 const val MAX_ANSWER_LENGTH = 240
const val MAX_SELECTIONS = 1 const val DEFAULT_MAX_SELECTIONS = 1
} }
@@ -20,10 +20,10 @@ import io.element.android.services.analytics.api.AnalyticsService
class DefaultSendPollResponseAction( class DefaultSendPollResponseAction(
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
) : SendPollResponseAction { ) : SendPollResponseAction {
override suspend fun execute(timeline: Timeline, pollStartId: EventId, answerId: String): Result<Unit> { override suspend fun execute(timeline: Timeline, pollStartId: EventId, answerIds: List<String>): Result<Unit> {
return timeline.sendPollResponse( return timeline.sendPollResponse(
pollStartId = pollStartId, pollStartId = pollStartId,
answers = listOf(answerId), answers = answerIds,
).onSuccess { ).onSuccess {
analyticsService.capture(PollVote()) analyticsService.capture(PollVote())
} }
@@ -18,6 +18,7 @@ sealed interface CreatePollEvent {
data object AddAnswer : CreatePollEvent data object AddAnswer : CreatePollEvent
data class RemoveAnswer(val index: Int) : CreatePollEvent data class RemoveAnswer(val index: Int) : CreatePollEvent
data class SetPollKind(val pollKind: PollKind) : CreatePollEvent data class SetPollKind(val pollKind: PollKind) : CreatePollEvent
data class SetMaxSelections(val maxSelections: Int) : CreatePollEvent
data object NavBack : CreatePollEvent data object NavBack : CreatePollEvent
data object ConfirmNavBack : CreatePollEvent data object ConfirmNavBack : CreatePollEvent
data object HideConfirmation : CreatePollEvent data object HideConfirmation : CreatePollEvent
@@ -24,7 +24,6 @@ import im.vector.app.features.analytics.plan.Composer
import im.vector.app.features.analytics.plan.PollCreation import im.vector.app.features.analytics.plan.PollCreation
import io.element.android.features.messages.api.MessageComposerContext import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.features.poll.api.create.CreatePollMode import io.element.android.features.poll.api.create.CreatePollMode
import io.element.android.features.poll.impl.PollConstants.MAX_SELECTIONS
import io.element.android.features.poll.impl.data.PollRepository import io.element.android.features.poll.impl.data.PollRepository
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.poll.PollAnswer import io.element.android.libraries.matrix.api.poll.PollAnswer
@@ -81,6 +80,7 @@ class CreatePollPresenter(
question = it.question, question = it.question,
answers = it.answers.map(PollAnswer::text).toImmutableList(), answers = it.answers.map(PollAnswer::text).toImmutableList(),
isDisclosed = it.kind.isDisclosed, isDisclosed = it.kind.isDisclosed,
maxSelections = it.maxSelections.toInt(),
) )
initialPoll = loadedPoll initialPoll = loadedPoll
poll = loadedPoll poll = loadedPoll
@@ -109,7 +109,7 @@ class CreatePollPresenter(
question = poll.question, question = poll.question,
answers = poll.answers, answers = poll.answers,
pollKind = poll.pollKind, pollKind = poll.pollKind,
maxSelections = MAX_SELECTIONS, maxSelections = poll.maxSelections,
).onSuccess { ).onSuccess {
analyticsService.capturePollSaved( analyticsService.capturePollSaved(
isUndisclosed = poll.pollKind == PollKind.Undisclosed, isUndisclosed = poll.pollKind == PollKind.Undisclosed,
@@ -151,6 +151,9 @@ class CreatePollPresenter(
is CreatePollEvent.SetPollKind -> { is CreatePollEvent.SetPollKind -> {
poll = poll.copy(isDisclosed = event.pollKind.isDisclosed) poll = poll.copy(isDisclosed = event.pollKind.isDisclosed)
} }
is CreatePollEvent.SetMaxSelections -> {
poll = poll.copy(maxSelections = event.maxSelections)
}
is CreatePollEvent.SetQuestion -> { is CreatePollEvent.SetQuestion -> {
poll = poll.copy(question = event.question) poll = poll.copy(question = event.question)
} }
@@ -182,6 +185,8 @@ class CreatePollPresenter(
question = poll.question, question = poll.question,
answers = immutableAnswers, answers = immutableAnswers,
pollKind = poll.pollKind, pollKind = poll.pollKind,
maxSelections = poll.maxSelections,
maxAllowedSelections = poll.answers.size,
showBackConfirmation = showBackConfirmation, showBackConfirmation = showBackConfirmation,
showDeleteConfirmation = showDeleteConfirmation, showDeleteConfirmation = showDeleteConfirmation,
eventSink = ::handleEvent, eventSink = ::handleEvent,
@@ -18,6 +18,8 @@ data class CreatePollState(
val question: String, val question: String,
val answers: ImmutableList<Answer>, val answers: ImmutableList<Answer>,
val pollKind: PollKind, val pollKind: PollKind,
val maxSelections: Int,
val maxAllowedSelections: Int,
val showBackConfirmation: Boolean, val showBackConfirmation: Boolean,
val showDeleteConfirmation: Boolean, val showDeleteConfirmation: Boolean,
val eventSink: (CreatePollEvent) -> Unit, val eventSink: (CreatePollEvent) -> Unit,
@@ -150,6 +150,21 @@ class CreatePollStatePreviewParam : PreviewParameterProvider<CreatePollState> {
showDeleteConfirmation = true, showDeleteConfirmation = true,
showBackConfirmation = false, showBackConfirmation = false,
), ),
aCreatePollState(
mode = CreatePollState.Mode.New,
canCreate = true,
canAddAnswer = true,
question = "What type of food should we have?",
answers = listOf(
Answer("Italian \uD83C\uDDEE\uD83C\uDDF9", false),
Answer("Chinese \uD83C\uDDE8\uD83C\uDDF3", false),
Answer("Brazilian \uD83C\uDDE7\uD83C\uDDF7", true),
),
showBackConfirmation = false,
showDeleteConfirmation = false,
pollKind = PollKind.Disclosed,
maxSelections = 2,
),
) )
} }
@@ -161,7 +176,8 @@ private fun aCreatePollState(
answers: List<Answer>, answers: List<Answer>,
showBackConfirmation: Boolean, showBackConfirmation: Boolean,
showDeleteConfirmation: Boolean, showDeleteConfirmation: Boolean,
pollKind: PollKind pollKind: PollKind,
maxSelections: Int = 1,
): CreatePollState { ): CreatePollState {
return CreatePollState( return CreatePollState(
mode = mode, mode = mode,
@@ -172,6 +188,8 @@ private fun aCreatePollState(
showBackConfirmation = showBackConfirmation, showBackConfirmation = showBackConfirmation,
showDeleteConfirmation = showDeleteConfirmation, showDeleteConfirmation = showDeleteConfirmation,
pollKind = pollKind, pollKind = pollKind,
maxSelections = maxSelections,
maxAllowedSelections = answers.size,
eventSink = {} eventSink = {}
) )
} }
@@ -11,11 +11,13 @@ package io.element.android.features.poll.impl.create
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@@ -25,6 +27,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
@@ -32,6 +35,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.poll.impl.R import io.element.android.features.poll.impl.R
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
@@ -173,6 +178,17 @@ fun CreatePollView(
item { item {
Column { Column {
HorizontalDivider() HorizontalDivider()
ListItem(
content = { Text(text = stringResource(id = R.string.screen_create_poll_max_selections_headline)) },
trailingContent = ListItemContent.Custom {
MaxSelectionsStepper(
current = state.maxSelections,
max = state.maxAllowedSelections,
onDecrement = { state.eventSink(CreatePollEvent.SetMaxSelections(state.maxSelections - 1)) },
onIncrement = { state.eventSink(CreatePollEvent.SetMaxSelections(state.maxSelections + 1)) },
)
},
)
ListItem( ListItem(
content = { Text(text = stringResource(id = R.string.screen_create_poll_anonymous_headline)) }, content = { Text(text = stringResource(id = R.string.screen_create_poll_anonymous_headline)) },
supportingContent = { Text(text = stringResource(id = R.string.screen_create_poll_anonymous_desc)) }, supportingContent = { Text(text = stringResource(id = R.string.screen_create_poll_anonymous_desc)) },
@@ -229,6 +245,52 @@ private fun CreatePollTopAppBar(
) )
} }
@Composable
private fun MaxSelectionsStepper(
current: Int,
max: Int,
onDecrement: () -> Unit,
onIncrement: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = CompoundIcons.Minus(),
contentDescription = stringResource(id = CommonStrings.a11y_create_poll_votes_allowed_decrease),
modifier = Modifier
.clickable(enabled = current > 1) { onDecrement() }
.size(32.dp)
.padding(4.dp),
tint = if (current > 1) {
ElementTheme.colors.bgAccentRest
} else {
ElementTheme.colors.iconDisabled
},
)
Text(
text = current.toString(),
modifier = Modifier.padding(horizontal = 12.dp),
style = ElementTheme.typography.fontBodyLgRegular,
)
Icon(
imageVector = CompoundIcons.Plus(),
contentDescription = stringResource(id = CommonStrings.a11y_create_poll_votes_allowed_increase),
modifier = Modifier
.clickable(enabled = current < max) { onIncrement() }
.size(32.dp)
.padding(4.dp),
tint = if (current < max) {
ElementTheme.colors.bgAccentRest
} else {
ElementTheme.colors.iconDisabled
},
)
}
}
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun CreatePollViewPreview( internal fun CreatePollViewPreview(
@@ -25,12 +25,14 @@ data class PollFormState(
val question: String, val question: String,
val answers: ImmutableList<String>, val answers: ImmutableList<String>,
val isDisclosed: Boolean, val isDisclosed: Boolean,
val maxSelections: Int,
) { ) {
companion object { companion object {
val Empty = PollFormState( val Empty = PollFormState(
question = "", question = "",
answers = MutableList(MIN_ANSWERS) { "" }.toImmutableList(), answers = MutableList(MIN_ANSWERS) { "" }.toImmutableList(),
isDisclosed = true, isDisclosed = true,
maxSelections = 1,
) )
} }
@@ -67,7 +69,9 @@ data class PollFormState(
return this return this
} }
return copy(answers = answers.filterIndexed { i, _ -> i != index }.toImmutableList()) val newAnswers = answers.filterIndexed { i, _ -> i != index }.toImmutableList()
val clampedMaxSelections = maxSelections.coerceIn(1, newAnswers.size)
return copy(answers = newAnswers, maxSelections = clampedMaxSelections)
} }
/** /**
@@ -110,6 +114,7 @@ internal val pollFormStateSaver = mapSaver(
"question" to it.question, "question" to it.question,
"answers" to it.answers.toTypedArray(), "answers" to it.answers.toTypedArray(),
"isDisclosed" to it.isDisclosed, "isDisclosed" to it.isDisclosed,
"maxSelections" to it.maxSelections,
) )
}, },
restore = { saved -> restore = { saved ->
@@ -117,6 +122,7 @@ internal val pollFormStateSaver = mapSaver(
question = saved["question"] as String, question = saved["question"] as String,
answers = (saved["answers"] as Array<*>).map { it as String }.toImmutableList(), answers = (saved["answers"] as Array<*>).map { it as String }.toImmutableList(),
isDisclosed = saved["isDisclosed"] as Boolean, isDisclosed = saved["isDisclosed"] as Boolean,
maxSelections = saved["maxSelections"] as Int,
) )
} }
) )
@@ -13,7 +13,7 @@ import io.element.android.libraries.matrix.api.core.EventId
sealed interface PollHistoryEvent { sealed interface PollHistoryEvent {
data object LoadMore : PollHistoryEvent data object LoadMore : PollHistoryEvent
data class SelectPollAnswer(val pollStartId: EventId, val answerId: String) : PollHistoryEvent data class SendPollResponse(val pollStartId: EventId, val answerIds: List<String>) : PollHistoryEvent
data class EndPoll(val pollStartId: EventId) : PollHistoryEvent data class EndPoll(val pollStartId: EventId) : PollHistoryEvent
data class SelectFilter(val filter: PollHistoryFilter) : PollHistoryEvent data class SelectFilter(val filter: PollHistoryFilter) : PollHistoryEvent
} }
@@ -31,6 +31,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber
@Inject @Inject
class PollHistoryPresenter( class PollHistoryPresenter(
@@ -68,12 +69,14 @@ class PollHistoryPresenter(
is PollHistoryEvent.LoadMore -> { is PollHistoryEvent.LoadMore -> {
coroutineScope.loadMore(timeline) coroutineScope.loadMore(timeline)
} }
is PollHistoryEvent.SelectPollAnswer -> sessionCoroutineScope.launch { is PollHistoryEvent.SendPollResponse -> sessionCoroutineScope.launch {
sendPollResponseAction.execute( sendPollResponseAction.execute(
timeline = timeline, timeline = timeline,
pollStartId = event.pollStartId, pollStartId = event.pollStartId,
answerId = event.answerId answerIds = event.answerIds
) ).onFailure { error ->
Timber.e(error, "Failed to send poll response")
}
} }
is PollHistoryEvent.EndPoll -> sessionCoroutineScope.launch { is PollHistoryEvent.EndPoll -> sessionCoroutineScope.launch {
endPollAction.execute(timeline = timeline, pollStartId = event.pollStartId) endPollAction.execute(timeline = timeline, pollStartId = event.pollStartId)
@@ -65,8 +65,8 @@ fun PollHistoryView(
state.eventSink(PollHistoryEvent.LoadMore) state.eventSink(PollHistoryEvent.LoadMore)
} }
fun onSelectAnswer(pollStartId: EventId, answerId: String) { fun onSendPollResponse(pollStartId: EventId, answerIds: List<String>) {
state.eventSink(PollHistoryEvent.SelectPollAnswer(pollStartId, answerId)) state.eventSink(PollHistoryEvent.SendPollResponse(pollStartId, answerIds))
} }
fun onEndPoll(pollStartId: EventId) { fun onEndPoll(pollStartId: EventId) {
@@ -114,7 +114,7 @@ fun PollHistoryView(
pollHistoryItems = pollHistoryItems, pollHistoryItems = pollHistoryItems,
hasMoreToLoad = state.hasMoreToLoad, hasMoreToLoad = state.hasMoreToLoad,
isLoading = state.isLoading, isLoading = state.isLoading,
onSelectAnswer = ::onSelectAnswer, onSendPollResponse = ::onSendPollResponse,
onEditPoll = onEditPoll, onEditPoll = onEditPoll,
onEndPoll = ::onEndPoll, onEndPoll = ::onEndPoll,
onLoadMore = ::onLoadMore, onLoadMore = ::onLoadMore,
@@ -151,7 +151,7 @@ private fun PollHistoryList(
pollHistoryItems: ImmutableList<PollHistoryItem>, pollHistoryItems: ImmutableList<PollHistoryItem>,
hasMoreToLoad: Boolean, hasMoreToLoad: Boolean,
isLoading: Boolean, isLoading: Boolean,
onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit, onSendPollResponse: (pollStartId: EventId, answerIds: List<String>) -> Unit,
onEditPoll: (pollStartId: EventId) -> Unit, onEditPoll: (pollStartId: EventId) -> Unit,
onEndPoll: (pollStartId: EventId) -> Unit, onEndPoll: (pollStartId: EventId) -> Unit,
onLoadMore: () -> Unit, onLoadMore: () -> Unit,
@@ -166,7 +166,7 @@ private fun PollHistoryList(
items(pollHistoryItems) { pollHistoryItem -> items(pollHistoryItems) { pollHistoryItem ->
PollHistoryItemRow( PollHistoryItemRow(
pollHistoryItem = pollHistoryItem, pollHistoryItem = pollHistoryItem,
onSelectAnswer = onSelectAnswer, onSendPollResponse = onSendPollResponse,
onEditPoll = onEditPoll, onEditPoll = onEditPoll,
onEndPoll = onEndPoll, onEndPoll = onEndPoll,
modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp) modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp)
@@ -222,7 +222,7 @@ private fun LoadMoreButton(isLoading: Boolean, onClick: () -> Unit) {
@Composable @Composable
private fun PollHistoryItemRow( private fun PollHistoryItemRow(
pollHistoryItem: PollHistoryItem, pollHistoryItem: PollHistoryItem,
onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit, onSendPollResponse: (pollStartId: EventId, answerIds: List<String>) -> Unit,
onEditPoll: (pollStartId: EventId) -> Unit, onEditPoll: (pollStartId: EventId) -> Unit,
onEndPoll: (pollStartId: EventId) -> Unit, onEndPoll: (pollStartId: EventId) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -244,7 +244,7 @@ private fun PollHistoryItemRow(
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
PollContentView( PollContentView(
state = pollHistoryItem.state, state = pollHistoryItem.state,
onSelectAnswer = onSelectAnswer, onSendPollResponse = onSendPollResponse,
onEditPoll = onEditPoll, onEditPoll = onEditPoll,
onEndPoll = onEndPoll, onEndPoll = onEndPoll,
) )
@@ -43,15 +43,21 @@ class DefaultPollContentStateFactory(
?.value ?.value
.orEmpty() .orEmpty()
} }
val currentSelectionCount = myVotes.size
val maxSelections = content.maxSelections.toInt()
val isSelectionLimitReached = currentSelectionCount >= maxSelections
val answerItems = content.answers.map { answer -> val answerItems = content.answers.map { answer ->
val answerVoteCount = content.votes[answer.id]?.size ?: 0 val answerVoteCount = content.votes[answer.id]?.size ?: 0
val isSelected = answer.id in myVotes val isSelected = answer.id in myVotes
val isWinner = answer.id in winnerIds val isWinner = answer.id in winnerIds
val percentage = if (totalVoteCount > 0) answerVoteCount.toFloat() / totalVoteCount.toFloat() else 0f val percentage = if (totalVoteCount > 0) answerVoteCount.toFloat() / totalVoteCount.toFloat() else 0f
// An answer is enabled if the poll hasn't ended and either the user can still select more answers or this answer is already selected
val isEnabled = !isPollEnded && (!isSelectionLimitReached || isSelected)
PollAnswerItem( PollAnswerItem(
answer = answer, answer = answer,
isSelected = isSelected, isSelected = isSelected,
isEnabled = !isPollEnded, isEnabled = isEnabled,
isWinner = isWinner, isWinner = isWinner,
showVotes = content.kind.isDisclosed || isPollEnded, showVotes = content.kind.isDisclosed || isPollEnded,
votesCount = answerVoteCount, votesCount = answerVoteCount,
@@ -64,6 +70,7 @@ class DefaultPollContentStateFactory(
question = content.question, question = content.question,
answerItems = answerItems.toImmutableList(), answerItems = answerItems.toImmutableList(),
pollKind = content.kind, pollKind = content.kind,
maxSelections = content.maxSelections,
isPollEditable = isEditable, isPollEditable = isEditable,
isPollEnded = isPollEnded, isPollEnded = isPollEnded,
isMine = isOwn, isMine = isOwn,
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_poll_max_selections_headline">"Votes allowed per person"</string>
</resources>
@@ -525,6 +525,8 @@ class CreatePollPresenterTest {
assertThat(question).isEmpty() assertThat(question).isEmpty()
assertThat(answers).isEqualTo(listOf(Answer("", false), Answer("", false))) assertThat(answers).isEqualTo(listOf(Answer("", false), Answer("", false)))
assertThat(pollKind).isEqualTo(PollKind.Disclosed) assertThat(pollKind).isEqualTo(PollKind.Disclosed)
assertThat(maxSelections).isEqualTo(1)
assertThat(maxAllowedSelections).isEqualTo(2)
assertThat(showBackConfirmation).isFalse() assertThat(showBackConfirmation).isFalse()
assertThat(showDeleteConfirmation).isFalse() assertThat(showDeleteConfirmation).isFalse()
} }
@@ -24,6 +24,7 @@ class PollFormStateSaverTest {
question = "question", question = "question",
answers = persistentListOf("answer1", "answer2"), answers = persistentListOf("answer1", "answer2"),
isDisclosed = true, isDisclosed = true,
maxSelections = 2,
) )
val saved = with(CanSaveScope) { val saved = with(CanSaveScope) {
@@ -117,7 +117,7 @@ class PollHistoryPresenterTest {
state.eventSink(PollHistoryEvent.EndPoll(AN_EVENT_ID)) state.eventSink(PollHistoryEvent.EndPoll(AN_EVENT_ID))
runCurrent() runCurrent()
endPollAction.verifyExecutionCount(1) endPollAction.verifyExecutionCount(1)
state.eventSink(PollHistoryEvent.SelectPollAnswer(AN_EVENT_ID, "answer")) state.eventSink(PollHistoryEvent.SendPollResponse(AN_EVENT_ID, listOf("answer")))
runCurrent() runCurrent()
sendPollResponseAction.verifyExecutionCount(1) sendPollResponseAction.verifyExecutionCount(1)
cancelAndConsumeRemainingEvents() cancelAndConsumeRemainingEvents()
@@ -133,7 +133,7 @@ class PollHistoryViewTest : RobolectricTest() {
useUnmergedTree = true, useUnmergedTree = true,
).performClick() ).performClick()
eventsRecorder.assertSingle( eventsRecorder.assertSingle(
PollHistoryEvent.SelectPollAnswer(eventId, answer.id) PollHistoryEvent.SendPollResponse(eventId, listOf(answer.id))
) )
} }
@@ -59,10 +59,10 @@ class PollContentStateFactoryTest {
) )
val expectedState = aPollContentState( val expectedState = aPollContentState(
answerItems = listOf( answerItems = listOf(
aPollAnswerItem(answer = A_POLL_ANSWER_1, votesCount = 3, percentage = 0.3f), aPollAnswerItem(answer = A_POLL_ANSWER_1, isEnabled = false, votesCount = 3, percentage = 0.3f),
aPollAnswerItem(answer = A_POLL_ANSWER_2, isSelected = true, votesCount = 6, percentage = 0.6f), aPollAnswerItem(answer = A_POLL_ANSWER_2, isSelected = true, votesCount = 6, percentage = 0.6f),
aPollAnswerItem(answer = A_POLL_ANSWER_3), aPollAnswerItem(answer = A_POLL_ANSWER_3, isEnabled = false),
aPollAnswerItem(answer = A_POLL_ANSWER_4, votesCount = 1, percentage = 0.1f), aPollAnswerItem(answer = A_POLL_ANSWER_4, isEnabled = false, votesCount = 1, percentage = 0.1f),
) )
) )
assertThat(state).isEqualTo(expectedState) assertThat(state).isEqualTo(expectedState)
@@ -139,10 +139,10 @@ class PollContentStateFactoryTest {
val expectedState = aPollContentState( val expectedState = aPollContentState(
pollKind = PollKind.Undisclosed, pollKind = PollKind.Undisclosed,
answerItems = listOf( answerItems = listOf(
aPollAnswerItem(answer = A_POLL_ANSWER_1, showVotes = false, votesCount = 3, percentage = 0.3f), aPollAnswerItem(answer = A_POLL_ANSWER_1, isEnabled = false, showVotes = false, votesCount = 3, percentage = 0.3f),
aPollAnswerItem(answer = A_POLL_ANSWER_2, showVotes = false, isSelected = true, votesCount = 6, percentage = 0.6f), aPollAnswerItem(answer = A_POLL_ANSWER_2, showVotes = false, isSelected = true, votesCount = 6, percentage = 0.6f),
aPollAnswerItem(answer = A_POLL_ANSWER_3, showVotes = false), aPollAnswerItem(answer = A_POLL_ANSWER_3, isEnabled = false, showVotes = false),
aPollAnswerItem(answer = A_POLL_ANSWER_4, showVotes = false, votesCount = 1, percentage = 0.1f), aPollAnswerItem(answer = A_POLL_ANSWER_4, isEnabled = false, showVotes = false, votesCount = 1, percentage = 0.1f),
), ),
) )
assertThat(state).isEqualTo(expectedState) assertThat(state).isEqualTo(expectedState)
@@ -241,6 +241,7 @@ class PollContentStateFactoryTest {
question = question, question = question,
answerItems = answerItems.toImmutableList(), answerItems = answerItems.toImmutableList(),
pollKind = pollKind, pollKind = pollKind,
maxSelections = 1u,
isPollEditable = isEditable, isPollEditable = isEditable,
isPollEnded = isEnded, isPollEnded = isEnded,
isMine = isMine, isMine = isMine,
@@ -19,7 +19,7 @@ class FakeSendPollResponseAction : SendPollResponseAction {
assert(executionCount == count) assert(executionCount == count)
} }
override suspend fun execute(timeline: Timeline, pollStartId: EventId, answerId: String): Result<Unit> { override suspend fun execute(timeline: Timeline, pollStartId: EventId, answerIds: List<String>): Result<Unit> {
executionCount++ executionCount++
return Result.success(Unit) return Result.success(Unit)
} }
@@ -22,6 +22,7 @@ class FakePollContentStateFactory : PollContentStateFactory {
question = content.question, question = content.question,
answerItems = emptyList<PollAnswerItem>().toImmutableList(), answerItems = emptyList<PollAnswerItem>().toImmutableList(),
pollKind = content.kind, pollKind = content.kind,
maxSelections = content.maxSelections,
isPollEditable = isEditable, isPollEditable = isEditable,
isPollEnded = content.endTime != null, isPollEnded = content.endTime != null,
isMine = isOwn, isMine = isOwn,
@@ -125,6 +125,7 @@ class KonsistPreviewTest {
"PollContentViewCreatorPreview", "PollContentViewCreatorPreview",
"PollContentViewDisclosedPreview", "PollContentViewDisclosedPreview",
"PollContentViewEndedPreview", "PollContentViewEndedPreview",
"PollContentViewMultipleSelectionPreview",
"PollContentViewUndisclosedPreview", "PollContentViewUndisclosedPreview",
"ProgressDialogWithContentPreview", "ProgressDialogWithContentPreview",
"ProgressDialogWithTextAndContentPreview", "ProgressDialogWithTextAndContentPreview",