mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 12:07:28 +00:00
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:
+2
-2
@@ -52,9 +52,9 @@ sealed interface TimelineEvent {
|
||||
*/
|
||||
sealed interface TimelineItemPollEvent : TimelineItemEvent
|
||||
|
||||
data class SelectPollAnswer(
|
||||
data class SendPollResponse(
|
||||
val pollStartId: EventId,
|
||||
val answerId: String
|
||||
val answerIds: List<String>
|
||||
) : TimelineItemPollEvent
|
||||
|
||||
data class EndPoll(
|
||||
|
||||
+5
-3
@@ -201,13 +201,15 @@ class TimelinePresenter(
|
||||
newEventState.value = NewEventState.None
|
||||
}
|
||||
}
|
||||
is TimelineEvent.SelectPollAnswer -> sessionCoroutineScope.launch {
|
||||
is TimelineEvent.SendPollResponse -> sessionCoroutineScope.launch {
|
||||
timelineController.invokeOnCurrentTimeline {
|
||||
sendPollResponseAction.execute(
|
||||
timeline = this,
|
||||
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 {
|
||||
|
||||
+5
-3
@@ -26,8 +26,8 @@ fun TimelineItemPollView(
|
||||
eventSink: (TimelineEvent.TimelineItemPollEvent) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun onSelectAnswer(pollStartId: EventId, answerId: String) {
|
||||
eventSink(TimelineEvent.SelectPollAnswer(pollStartId, answerId))
|
||||
fun onSendPollResponse(pollStartId: EventId, answerIds: List<String>) {
|
||||
eventSink(TimelineEvent.SendPollResponse(pollStartId, answerIds))
|
||||
}
|
||||
|
||||
fun onEndPoll(pollStartId: EventId) {
|
||||
@@ -43,10 +43,12 @@ fun TimelineItemPollView(
|
||||
question = content.question,
|
||||
answerItems = content.answerItems.toImmutableList(),
|
||||
pollKind = content.pollKind,
|
||||
isMultipleSelection = content.maxSelections > 1u,
|
||||
maxSelections = content.maxSelections,
|
||||
isPollEnded = content.isEnded,
|
||||
isPollEditable = content.isEditable,
|
||||
isMine = content.isMine,
|
||||
onSelectAnswer = ::onSelectAnswer,
|
||||
onSendPollResponse = ::onSendPollResponse,
|
||||
onEditPoll = ::onEditPoll,
|
||||
onEndPoll = ::onEndPoll,
|
||||
modifier = modifier,
|
||||
|
||||
+1
@@ -33,6 +33,7 @@ class TimelineItemContentPollFactory(
|
||||
question = pollContentState.question,
|
||||
answerItems = pollContentState.answerItems,
|
||||
pollKind = pollContentState.pollKind,
|
||||
maxSelections = pollContentState.maxSelections,
|
||||
isEnded = pollContentState.isPollEnded,
|
||||
isEdited = content.isEdited
|
||||
)
|
||||
|
||||
+1
@@ -19,6 +19,7 @@ data class TimelineItemPollContent(
|
||||
val question: String,
|
||||
val answerItems: List<PollAnswerItem>,
|
||||
val pollKind: PollKind,
|
||||
val maxSelections: ULong,
|
||||
val isEnded: Boolean,
|
||||
override val isEdited: Boolean,
|
||||
) : TimelineItemEventContent,
|
||||
|
||||
+2
@@ -32,12 +32,14 @@ fun aTimelineItemPollContent(
|
||||
isEditable: Boolean = false,
|
||||
isEnded: Boolean = false,
|
||||
isEdited: Boolean = false,
|
||||
maxSelections: ULong = 1u,
|
||||
): TimelineItemPollContent {
|
||||
return TimelineItemPollContent(
|
||||
eventId = EventId("\$anEventId"),
|
||||
pollKind = PollKind.Disclosed,
|
||||
question = question,
|
||||
answerItems = answerItems,
|
||||
maxSelections = maxSelections,
|
||||
isMine = isMine,
|
||||
isEditable = isEditable,
|
||||
isEnded = isEnded,
|
||||
|
||||
+2
-2
@@ -951,14 +951,14 @@ class TimelinePresenterTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - PollAnswerSelected event`() = runTest {
|
||||
fun `present - SendPollResponse event`() = runTest {
|
||||
val sendPollResponseAction = FakeSendPollResponseAction()
|
||||
val presenter = createTimelinePresenter(
|
||||
sendPollResponseAction = sendPollResponseAction,
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink.invoke(TimelineEvent.SelectPollAnswer(AN_EVENT_ID, "anAnswerId"))
|
||||
initialState.eventSink.invoke(TimelineEvent.SendPollResponse(AN_EVENT_ID, listOf("anAnswerId")))
|
||||
}
|
||||
delay(1)
|
||||
sendPollResponseAction.verifyExecutionCount(1)
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ class TimelineItemPollViewTest : RobolectricTest() {
|
||||
matcher = hasText(answer.text),
|
||||
useUnmergedTree = true,
|
||||
).performClick()
|
||||
eventsRecorder.assertSingle(TimelineEvent.SelectPollAnswer(content.eventId!!, answer.id))
|
||||
eventsRecorder.assertSingle(TimelineEvent.SendPollResponse(content.eventId!!, listOf(answer.id)))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+3
-3
@@ -12,17 +12,17 @@ import io.element.android.libraries.matrix.api.core.EventId
|
||||
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 {
|
||||
/**
|
||||
* @param timeline the timeline the poll lives in.
|
||||
* @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(
|
||||
timeline: Timeline,
|
||||
pollStartId: EventId,
|
||||
answerId: String
|
||||
answerIds: List<String>,
|
||||
): Result<Unit>
|
||||
}
|
||||
|
||||
+5
-1
@@ -18,6 +18,7 @@ import kotlinx.collections.immutable.ImmutableList
|
||||
* @property question the poll question.
|
||||
* @property answerItems the list of answers.
|
||||
* @property pollKind the kind of poll.
|
||||
* @property maxSelections the maximum number of selections allowed.
|
||||
* @property isPollEditable whether the poll is editable.
|
||||
* @property isPollEnded whether the poll is ended.
|
||||
* @property isMine whether the poll has been created by me.
|
||||
@@ -27,7 +28,10 @@ data class PollContentState(
|
||||
val question: String,
|
||||
val answerItems: ImmutableList<PollAnswerItem>,
|
||||
val pollKind: PollKind,
|
||||
val maxSelections: ULong,
|
||||
val isPollEditable: Boolean,
|
||||
val isPollEnded: Boolean,
|
||||
val isMine: Boolean,
|
||||
)
|
||||
) {
|
||||
val isMultipleSelection = maxSelections > 1u
|
||||
}
|
||||
|
||||
+2
@@ -84,6 +84,7 @@ fun aPollContentState(
|
||||
hasVotes: Boolean = true,
|
||||
question: String = aPollQuestion(),
|
||||
pollKind: PollKind = PollKind.Disclosed,
|
||||
maxSelections: ULong = 1u,
|
||||
answerItems: ImmutableList<PollAnswerItem> = aPollAnswerItemList(
|
||||
isEnded = isEnded,
|
||||
showVotes = showVotes,
|
||||
@@ -94,6 +95,7 @@ fun aPollContentState(
|
||||
question = question,
|
||||
answerItems = answerItems,
|
||||
pollKind = pollKind,
|
||||
maxSelections = maxSelections,
|
||||
isPollEditable = isMine && !isEnded && isPollEditable,
|
||||
isPollEnded = isEnded,
|
||||
isMine = isMine,
|
||||
|
||||
+69
-12
@@ -40,7 +40,7 @@ import kotlinx.collections.immutable.ImmutableList
|
||||
@Composable
|
||||
fun PollContentView(
|
||||
state: PollContentState,
|
||||
onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit,
|
||||
onSendPollResponse: (pollStartId: EventId, answerIds: List<String>) -> Unit,
|
||||
onEditPoll: (pollStartId: EventId) -> Unit,
|
||||
onEndPoll: (pollStartId: EventId) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -50,11 +50,13 @@ fun PollContentView(
|
||||
question = state.question,
|
||||
answerItems = state.answerItems,
|
||||
pollKind = state.pollKind,
|
||||
isMultipleSelection = state.isMultipleSelection,
|
||||
maxSelections = state.maxSelections,
|
||||
isPollEditable = state.isPollEditable,
|
||||
isPollEnded = state.isPollEnded,
|
||||
isMine = state.isMine,
|
||||
onEditPoll = onEditPoll,
|
||||
onSelectAnswer = onSelectAnswer,
|
||||
onSendPollResponse = onSendPollResponse,
|
||||
onEndPoll = onEndPoll,
|
||||
modifier = modifier,
|
||||
)
|
||||
@@ -66,10 +68,12 @@ fun PollContentView(
|
||||
question: String,
|
||||
answerItems: ImmutableList<PollAnswerItem>,
|
||||
pollKind: PollKind,
|
||||
isMultipleSelection: Boolean,
|
||||
maxSelections: ULong,
|
||||
isPollEditable: Boolean,
|
||||
isPollEnded: Boolean,
|
||||
isMine: Boolean,
|
||||
onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit,
|
||||
onSendPollResponse: (pollStartId: EventId, answerIds: List<String>) -> Unit,
|
||||
onEditPoll: (pollStartId: EventId) -> Unit,
|
||||
onEndPoll: (pollStartId: EventId) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -77,7 +81,23 @@ fun PollContentView(
|
||||
val votesCount = remember(answerItems) { answerItems.sumOf { it.votesCount } }
|
||||
|
||||
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() {
|
||||
@@ -107,7 +127,11 @@ fun PollContentView(
|
||||
) {
|
||||
PollTitleView(title = question, isPollEnded = isPollEnded)
|
||||
|
||||
PollAnswers(answerItems = answerItems, onSelectAnswer = ::onSelectAnswer)
|
||||
PollAnswers(
|
||||
answerItems = answerItems,
|
||||
isMultipleSelection = isMultipleSelection,
|
||||
onSelectAnswer = ::onSelectAnswer,
|
||||
)
|
||||
|
||||
if (isPollEnded || pollKind == PollKind.Disclosed) {
|
||||
DisclosedPollBottomNotice(votesCount = votesCount)
|
||||
@@ -130,8 +154,10 @@ fun PollContentView(
|
||||
@Composable
|
||||
private fun PollAnswers(
|
||||
answerItems: ImmutableList<PollAnswerItem>,
|
||||
isMultipleSelection: Boolean,
|
||||
onSelectAnswer: (PollAnswer) -> Unit,
|
||||
) {
|
||||
val role = if (isMultipleSelection) Role.Checkbox else Role.RadioButton
|
||||
Column(
|
||||
modifier = Modifier.selectableGroup(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
@@ -144,7 +170,7 @@ private fun PollAnswers(
|
||||
selected = it.isSelected,
|
||||
enabled = it.isEnabled,
|
||||
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?",
|
||||
answerItems = aPollAnswerItemList(showVotes = false),
|
||||
pollKind = PollKind.Undisclosed,
|
||||
isMultipleSelection = false,
|
||||
maxSelections = 1u,
|
||||
isPollEnded = false,
|
||||
isPollEditable = false,
|
||||
isMine = false,
|
||||
onSelectAnswer = { _, _ -> },
|
||||
onSendPollResponse = { _, _ -> },
|
||||
onEditPoll = {},
|
||||
onEndPoll = {},
|
||||
)
|
||||
@@ -224,10 +252,31 @@ internal fun PollContentViewDisclosedPreview() = ElementPreview {
|
||||
question = "What type of food should we have at the party?",
|
||||
answerItems = aPollAnswerItemList(),
|
||||
pollKind = PollKind.Disclosed,
|
||||
isMultipleSelection = false,
|
||||
maxSelections = 1u,
|
||||
isPollEnded = false,
|
||||
isPollEditable = 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 = {},
|
||||
onEndPoll = {},
|
||||
)
|
||||
@@ -241,10 +290,12 @@ internal fun PollContentViewEndedPreview() = ElementPreview {
|
||||
question = "What type of food should we have at the party?",
|
||||
answerItems = aPollAnswerItemList(isEnded = true),
|
||||
pollKind = PollKind.Disclosed,
|
||||
isMultipleSelection = false,
|
||||
maxSelections = 1u,
|
||||
isPollEnded = true,
|
||||
isPollEditable = false,
|
||||
isMine = false,
|
||||
onSelectAnswer = { _, _ -> },
|
||||
onSendPollResponse = { _, _ -> },
|
||||
onEditPoll = {},
|
||||
onEndPoll = {},
|
||||
)
|
||||
@@ -258,10 +309,12 @@ internal fun PollContentViewCreatorEditablePreview() = ElementPreview {
|
||||
question = "What type of food should we have at the party?",
|
||||
answerItems = aPollAnswerItemList(hasVotes = false, isEnded = false),
|
||||
pollKind = PollKind.Disclosed,
|
||||
isMultipleSelection = false,
|
||||
maxSelections = 1u,
|
||||
isPollEnded = false,
|
||||
isPollEditable = true,
|
||||
isMine = true,
|
||||
onSelectAnswer = { _, _ -> },
|
||||
onSendPollResponse = { _, _ -> },
|
||||
onEditPoll = {},
|
||||
onEndPoll = {},
|
||||
)
|
||||
@@ -275,10 +328,12 @@ internal fun PollContentViewCreatorPreview() = ElementPreview {
|
||||
question = "What type of food should we have at the party?",
|
||||
answerItems = aPollAnswerItemList(isEnded = false),
|
||||
pollKind = PollKind.Disclosed,
|
||||
isMultipleSelection = false,
|
||||
maxSelections = 1u,
|
||||
isPollEnded = false,
|
||||
isPollEditable = false,
|
||||
isMine = true,
|
||||
onSelectAnswer = { _, _ -> },
|
||||
onSendPollResponse = { _, _ -> },
|
||||
onEditPoll = {},
|
||||
onEndPoll = {},
|
||||
)
|
||||
@@ -292,10 +347,12 @@ internal fun PollContentViewCreatorEndedPreview() = ElementPreview {
|
||||
question = "What type of food should we have at the party?",
|
||||
answerItems = aPollAnswerItemList(isEnded = true),
|
||||
pollKind = PollKind.Disclosed,
|
||||
isMultipleSelection = false,
|
||||
maxSelections = 1u,
|
||||
isPollEnded = true,
|
||||
isPollEditable = false,
|
||||
isMine = true,
|
||||
onSelectAnswer = { _, _ -> },
|
||||
onSendPollResponse = { _, _ -> },
|
||||
onEditPoll = {},
|
||||
onEndPoll = {},
|
||||
)
|
||||
|
||||
+1
-1
@@ -12,5 +12,5 @@ internal object PollConstants {
|
||||
const val MIN_ANSWERS = 2
|
||||
const val MAX_ANSWERS = 20
|
||||
const val MAX_ANSWER_LENGTH = 240
|
||||
const val MAX_SELECTIONS = 1
|
||||
const val DEFAULT_MAX_SELECTIONS = 1
|
||||
}
|
||||
|
||||
+2
-2
@@ -20,10 +20,10 @@ import io.element.android.services.analytics.api.AnalyticsService
|
||||
class DefaultSendPollResponseAction(
|
||||
private val analyticsService: AnalyticsService,
|
||||
) : 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(
|
||||
pollStartId = pollStartId,
|
||||
answers = listOf(answerId),
|
||||
answers = answerIds,
|
||||
).onSuccess {
|
||||
analyticsService.capture(PollVote())
|
||||
}
|
||||
|
||||
+1
@@ -18,6 +18,7 @@ sealed interface CreatePollEvent {
|
||||
data object AddAnswer : CreatePollEvent
|
||||
data class RemoveAnswer(val index: Int) : CreatePollEvent
|
||||
data class SetPollKind(val pollKind: PollKind) : CreatePollEvent
|
||||
data class SetMaxSelections(val maxSelections: Int) : CreatePollEvent
|
||||
data object NavBack : CreatePollEvent
|
||||
data object ConfirmNavBack : CreatePollEvent
|
||||
data object HideConfirmation : CreatePollEvent
|
||||
|
||||
+7
-2
@@ -24,7 +24,6 @@ import im.vector.app.features.analytics.plan.Composer
|
||||
import im.vector.app.features.analytics.plan.PollCreation
|
||||
import io.element.android.features.messages.api.MessageComposerContext
|
||||
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.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.poll.PollAnswer
|
||||
@@ -81,6 +80,7 @@ class CreatePollPresenter(
|
||||
question = it.question,
|
||||
answers = it.answers.map(PollAnswer::text).toImmutableList(),
|
||||
isDisclosed = it.kind.isDisclosed,
|
||||
maxSelections = it.maxSelections.toInt(),
|
||||
)
|
||||
initialPoll = loadedPoll
|
||||
poll = loadedPoll
|
||||
@@ -109,7 +109,7 @@ class CreatePollPresenter(
|
||||
question = poll.question,
|
||||
answers = poll.answers,
|
||||
pollKind = poll.pollKind,
|
||||
maxSelections = MAX_SELECTIONS,
|
||||
maxSelections = poll.maxSelections,
|
||||
).onSuccess {
|
||||
analyticsService.capturePollSaved(
|
||||
isUndisclosed = poll.pollKind == PollKind.Undisclosed,
|
||||
@@ -151,6 +151,9 @@ class CreatePollPresenter(
|
||||
is CreatePollEvent.SetPollKind -> {
|
||||
poll = poll.copy(isDisclosed = event.pollKind.isDisclosed)
|
||||
}
|
||||
is CreatePollEvent.SetMaxSelections -> {
|
||||
poll = poll.copy(maxSelections = event.maxSelections)
|
||||
}
|
||||
is CreatePollEvent.SetQuestion -> {
|
||||
poll = poll.copy(question = event.question)
|
||||
}
|
||||
@@ -182,6 +185,8 @@ class CreatePollPresenter(
|
||||
question = poll.question,
|
||||
answers = immutableAnswers,
|
||||
pollKind = poll.pollKind,
|
||||
maxSelections = poll.maxSelections,
|
||||
maxAllowedSelections = poll.answers.size,
|
||||
showBackConfirmation = showBackConfirmation,
|
||||
showDeleteConfirmation = showDeleteConfirmation,
|
||||
eventSink = ::handleEvent,
|
||||
|
||||
+2
@@ -18,6 +18,8 @@ data class CreatePollState(
|
||||
val question: String,
|
||||
val answers: ImmutableList<Answer>,
|
||||
val pollKind: PollKind,
|
||||
val maxSelections: Int,
|
||||
val maxAllowedSelections: Int,
|
||||
val showBackConfirmation: Boolean,
|
||||
val showDeleteConfirmation: Boolean,
|
||||
val eventSink: (CreatePollEvent) -> Unit,
|
||||
|
||||
+19
-1
@@ -150,6 +150,21 @@ class CreatePollStatePreviewParam : PreviewParameterProvider<CreatePollState> {
|
||||
showDeleteConfirmation = true,
|
||||
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>,
|
||||
showBackConfirmation: Boolean,
|
||||
showDeleteConfirmation: Boolean,
|
||||
pollKind: PollKind
|
||||
pollKind: PollKind,
|
||||
maxSelections: Int = 1,
|
||||
): CreatePollState {
|
||||
return CreatePollState(
|
||||
mode = mode,
|
||||
@@ -172,6 +188,8 @@ private fun aCreatePollState(
|
||||
showBackConfirmation = showBackConfirmation,
|
||||
showDeleteConfirmation = showDeleteConfirmation,
|
||||
pollKind = pollKind,
|
||||
maxSelections = maxSelections,
|
||||
maxAllowedSelections = answers.size,
|
||||
eventSink = {}
|
||||
)
|
||||
}
|
||||
|
||||
+62
@@ -11,11 +11,13 @@ package io.element.android.features.poll.impl.create
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
@@ -25,6 +27,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.KeyboardCapitalization
|
||||
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.features.poll.impl.R
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
@@ -173,6 +178,17 @@ fun CreatePollView(
|
||||
item {
|
||||
Column {
|
||||
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(
|
||||
content = { Text(text = stringResource(id = R.string.screen_create_poll_anonymous_headline)) },
|
||||
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
|
||||
@Composable
|
||||
internal fun CreatePollViewPreview(
|
||||
|
||||
+7
-1
@@ -25,12 +25,14 @@ data class PollFormState(
|
||||
val question: String,
|
||||
val answers: ImmutableList<String>,
|
||||
val isDisclosed: Boolean,
|
||||
val maxSelections: Int,
|
||||
) {
|
||||
companion object {
|
||||
val Empty = PollFormState(
|
||||
question = "",
|
||||
answers = MutableList(MIN_ANSWERS) { "" }.toImmutableList(),
|
||||
isDisclosed = true,
|
||||
maxSelections = 1,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -67,7 +69,9 @@ data class PollFormState(
|
||||
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,
|
||||
"answers" to it.answers.toTypedArray(),
|
||||
"isDisclosed" to it.isDisclosed,
|
||||
"maxSelections" to it.maxSelections,
|
||||
)
|
||||
},
|
||||
restore = { saved ->
|
||||
@@ -117,6 +122,7 @@ internal val pollFormStateSaver = mapSaver(
|
||||
question = saved["question"] as String,
|
||||
answers = (saved["answers"] as Array<*>).map { it as String }.toImmutableList(),
|
||||
isDisclosed = saved["isDisclosed"] as Boolean,
|
||||
maxSelections = saved["maxSelections"] as Int,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ import io.element.android.libraries.matrix.api.core.EventId
|
||||
|
||||
sealed interface 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 SelectFilter(val filter: PollHistoryFilter) : PollHistoryEvent
|
||||
}
|
||||
|
||||
+6
-3
@@ -31,6 +31,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
@Inject
|
||||
class PollHistoryPresenter(
|
||||
@@ -68,12 +69,14 @@ class PollHistoryPresenter(
|
||||
is PollHistoryEvent.LoadMore -> {
|
||||
coroutineScope.loadMore(timeline)
|
||||
}
|
||||
is PollHistoryEvent.SelectPollAnswer -> sessionCoroutineScope.launch {
|
||||
is PollHistoryEvent.SendPollResponse -> sessionCoroutineScope.launch {
|
||||
sendPollResponseAction.execute(
|
||||
timeline = timeline,
|
||||
pollStartId = event.pollStartId,
|
||||
answerId = event.answerId
|
||||
)
|
||||
answerIds = event.answerIds
|
||||
).onFailure { error ->
|
||||
Timber.e(error, "Failed to send poll response")
|
||||
}
|
||||
}
|
||||
is PollHistoryEvent.EndPoll -> sessionCoroutineScope.launch {
|
||||
endPollAction.execute(timeline = timeline, pollStartId = event.pollStartId)
|
||||
|
||||
+7
-7
@@ -65,8 +65,8 @@ fun PollHistoryView(
|
||||
state.eventSink(PollHistoryEvent.LoadMore)
|
||||
}
|
||||
|
||||
fun onSelectAnswer(pollStartId: EventId, answerId: String) {
|
||||
state.eventSink(PollHistoryEvent.SelectPollAnswer(pollStartId, answerId))
|
||||
fun onSendPollResponse(pollStartId: EventId, answerIds: List<String>) {
|
||||
state.eventSink(PollHistoryEvent.SendPollResponse(pollStartId, answerIds))
|
||||
}
|
||||
|
||||
fun onEndPoll(pollStartId: EventId) {
|
||||
@@ -114,7 +114,7 @@ fun PollHistoryView(
|
||||
pollHistoryItems = pollHistoryItems,
|
||||
hasMoreToLoad = state.hasMoreToLoad,
|
||||
isLoading = state.isLoading,
|
||||
onSelectAnswer = ::onSelectAnswer,
|
||||
onSendPollResponse = ::onSendPollResponse,
|
||||
onEditPoll = onEditPoll,
|
||||
onEndPoll = ::onEndPoll,
|
||||
onLoadMore = ::onLoadMore,
|
||||
@@ -151,7 +151,7 @@ private fun PollHistoryList(
|
||||
pollHistoryItems: ImmutableList<PollHistoryItem>,
|
||||
hasMoreToLoad: Boolean,
|
||||
isLoading: Boolean,
|
||||
onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit,
|
||||
onSendPollResponse: (pollStartId: EventId, answerIds: List<String>) -> Unit,
|
||||
onEditPoll: (pollStartId: EventId) -> Unit,
|
||||
onEndPoll: (pollStartId: EventId) -> Unit,
|
||||
onLoadMore: () -> Unit,
|
||||
@@ -166,7 +166,7 @@ private fun PollHistoryList(
|
||||
items(pollHistoryItems) { pollHistoryItem ->
|
||||
PollHistoryItemRow(
|
||||
pollHistoryItem = pollHistoryItem,
|
||||
onSelectAnswer = onSelectAnswer,
|
||||
onSendPollResponse = onSendPollResponse,
|
||||
onEditPoll = onEditPoll,
|
||||
onEndPoll = onEndPoll,
|
||||
modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp)
|
||||
@@ -222,7 +222,7 @@ private fun LoadMoreButton(isLoading: Boolean, onClick: () -> Unit) {
|
||||
@Composable
|
||||
private fun PollHistoryItemRow(
|
||||
pollHistoryItem: PollHistoryItem,
|
||||
onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit,
|
||||
onSendPollResponse: (pollStartId: EventId, answerIds: List<String>) -> Unit,
|
||||
onEditPoll: (pollStartId: EventId) -> Unit,
|
||||
onEndPoll: (pollStartId: EventId) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -244,7 +244,7 @@ private fun PollHistoryItemRow(
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
PollContentView(
|
||||
state = pollHistoryItem.state,
|
||||
onSelectAnswer = onSelectAnswer,
|
||||
onSendPollResponse = onSendPollResponse,
|
||||
onEditPoll = onEditPoll,
|
||||
onEndPoll = onEndPoll,
|
||||
)
|
||||
|
||||
+8
-1
@@ -43,15 +43,21 @@ class DefaultPollContentStateFactory(
|
||||
?.value
|
||||
.orEmpty()
|
||||
}
|
||||
val currentSelectionCount = myVotes.size
|
||||
val maxSelections = content.maxSelections.toInt()
|
||||
val isSelectionLimitReached = currentSelectionCount >= maxSelections
|
||||
|
||||
val answerItems = content.answers.map { answer ->
|
||||
val answerVoteCount = content.votes[answer.id]?.size ?: 0
|
||||
val isSelected = answer.id in myVotes
|
||||
val isWinner = answer.id in winnerIds
|
||||
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(
|
||||
answer = answer,
|
||||
isSelected = isSelected,
|
||||
isEnabled = !isPollEnded,
|
||||
isEnabled = isEnabled,
|
||||
isWinner = isWinner,
|
||||
showVotes = content.kind.isDisclosed || isPollEnded,
|
||||
votesCount = answerVoteCount,
|
||||
@@ -64,6 +70,7 @@ class DefaultPollContentStateFactory(
|
||||
question = content.question,
|
||||
answerItems = answerItems.toImmutableList(),
|
||||
pollKind = content.kind,
|
||||
maxSelections = content.maxSelections,
|
||||
isPollEditable = isEditable,
|
||||
isPollEnded = isPollEnded,
|
||||
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>
|
||||
+2
@@ -525,6 +525,8 @@ class CreatePollPresenterTest {
|
||||
assertThat(question).isEmpty()
|
||||
assertThat(answers).isEqualTo(listOf(Answer("", false), Answer("", false)))
|
||||
assertThat(pollKind).isEqualTo(PollKind.Disclosed)
|
||||
assertThat(maxSelections).isEqualTo(1)
|
||||
assertThat(maxAllowedSelections).isEqualTo(2)
|
||||
assertThat(showBackConfirmation).isFalse()
|
||||
assertThat(showDeleteConfirmation).isFalse()
|
||||
}
|
||||
|
||||
+1
@@ -24,6 +24,7 @@ class PollFormStateSaverTest {
|
||||
question = "question",
|
||||
answers = persistentListOf("answer1", "answer2"),
|
||||
isDisclosed = true,
|
||||
maxSelections = 2,
|
||||
)
|
||||
|
||||
val saved = with(CanSaveScope) {
|
||||
|
||||
+1
-1
@@ -117,7 +117,7 @@ class PollHistoryPresenterTest {
|
||||
state.eventSink(PollHistoryEvent.EndPoll(AN_EVENT_ID))
|
||||
runCurrent()
|
||||
endPollAction.verifyExecutionCount(1)
|
||||
state.eventSink(PollHistoryEvent.SelectPollAnswer(AN_EVENT_ID, "answer"))
|
||||
state.eventSink(PollHistoryEvent.SendPollResponse(AN_EVENT_ID, listOf("answer")))
|
||||
runCurrent()
|
||||
sendPollResponseAction.verifyExecutionCount(1)
|
||||
cancelAndConsumeRemainingEvents()
|
||||
|
||||
+1
-1
@@ -133,7 +133,7 @@ class PollHistoryViewTest : RobolectricTest() {
|
||||
useUnmergedTree = true,
|
||||
).performClick()
|
||||
eventsRecorder.assertSingle(
|
||||
PollHistoryEvent.SelectPollAnswer(eventId, answer.id)
|
||||
PollHistoryEvent.SendPollResponse(eventId, listOf(answer.id))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+7
-6
@@ -59,10 +59,10 @@ class PollContentStateFactoryTest {
|
||||
)
|
||||
val expectedState = aPollContentState(
|
||||
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_3),
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_4, votesCount = 1, percentage = 0.1f),
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_3, isEnabled = false),
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_4, isEnabled = false, votesCount = 1, percentage = 0.1f),
|
||||
)
|
||||
)
|
||||
assertThat(state).isEqualTo(expectedState)
|
||||
@@ -139,10 +139,10 @@ class PollContentStateFactoryTest {
|
||||
val expectedState = aPollContentState(
|
||||
pollKind = PollKind.Undisclosed,
|
||||
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_3, showVotes = false),
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_4, showVotes = false, votesCount = 1, percentage = 0.1f),
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_3, isEnabled = false, showVotes = false),
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_4, isEnabled = false, showVotes = false, votesCount = 1, percentage = 0.1f),
|
||||
),
|
||||
)
|
||||
assertThat(state).isEqualTo(expectedState)
|
||||
@@ -241,6 +241,7 @@ class PollContentStateFactoryTest {
|
||||
question = question,
|
||||
answerItems = answerItems.toImmutableList(),
|
||||
pollKind = pollKind,
|
||||
maxSelections = 1u,
|
||||
isPollEditable = isEditable,
|
||||
isPollEnded = isEnded,
|
||||
isMine = isMine,
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ class FakeSendPollResponseAction : SendPollResponseAction {
|
||||
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++
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
+1
@@ -22,6 +22,7 @@ class FakePollContentStateFactory : PollContentStateFactory {
|
||||
question = content.question,
|
||||
answerItems = emptyList<PollAnswerItem>().toImmutableList(),
|
||||
pollKind = content.kind,
|
||||
maxSelections = content.maxSelections,
|
||||
isPollEditable = isEditable,
|
||||
isPollEnded = content.endTime != null,
|
||||
isMine = isOwn,
|
||||
|
||||
@@ -125,6 +125,7 @@ class KonsistPreviewTest {
|
||||
"PollContentViewCreatorPreview",
|
||||
"PollContentViewDisclosedPreview",
|
||||
"PollContentViewEndedPreview",
|
||||
"PollContentViewMultipleSelectionPreview",
|
||||
"PollContentViewUndisclosedPreview",
|
||||
"ProgressDialogWithContentPreview",
|
||||
"ProgressDialogWithTextAndContentPreview",
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Reference in New Issue
Block a user