mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-10-31 16:22:49 +00:00
Max selections support
This commit is contained in:
+1
-1
@@ -47,7 +47,7 @@ sealed interface TimelineEvent {
|
||||
|
||||
data class SelectPollAnswer(
|
||||
val pollStartId: EventId,
|
||||
val answerId: String
|
||||
val answerIds: List<String>
|
||||
) : TimelineItemPollEvent
|
||||
|
||||
data class EndPoll(
|
||||
|
||||
+4
-2
@@ -182,8 +182,10 @@ class TimelinePresenter(
|
||||
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 {
|
||||
|
||||
+4
-2
@@ -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 onSelectAnswer(pollStartId: EventId, answerIds: List<String>) {
|
||||
eventSink(TimelineEvent.SelectPollAnswer(pollStartId, answerIds))
|
||||
}
|
||||
|
||||
fun onEndPoll(pollStartId: EventId) {
|
||||
@@ -43,6 +43,8 @@ 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,
|
||||
|
||||
+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,
|
||||
|
||||
+1
-1
@@ -428,7 +428,7 @@ class TimelinePresenterTest {
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink.invoke(TimelineEvent.SelectPollAnswer(AN_EVENT_ID, "anAnswerId"))
|
||||
initialState.eventSink.invoke(TimelineEvent.SelectPollAnswer(AN_EVENT_ID, listOf("anAnswerId")))
|
||||
}
|
||||
delay(1)
|
||||
sendPollResponseAction.verifyExecutionCount(1)
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ class TimelineItemPollViewTest {
|
||||
matcher = hasText(answer.text),
|
||||
useUnmergedTree = true,
|
||||
).performClick()
|
||||
eventsRecorder.assertSingle(TimelineEvent.SelectPollAnswer(content.eventId!!, answer.id))
|
||||
eventsRecorder.assertSingle(TimelineEvent.SelectPollAnswer(content.eventId!!, listOf(answer.id)))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+1
-1
@@ -15,6 +15,6 @@ interface SendPollResponseAction {
|
||||
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: Boolean get() = 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,
|
||||
|
||||
+62
-5
@@ -40,7 +40,7 @@ import kotlinx.collections.immutable.ImmutableList
|
||||
@Composable
|
||||
fun PollContentView(
|
||||
state: PollContentState,
|
||||
onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit,
|
||||
onSelectAnswer: (pollStartId: EventId, answerIds: List<String>) -> Unit,
|
||||
onEditPoll: (pollStartId: EventId) -> Unit,
|
||||
onEndPoll: (pollStartId: EventId) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -50,6 +50,8 @@ 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,
|
||||
@@ -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,
|
||||
onSelectAnswer: (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()) {
|
||||
onSelectAnswer(it, newSelections)
|
||||
}
|
||||
} else {
|
||||
onSelectAnswer(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,6 +233,8 @@ 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,
|
||||
@@ -224,6 +252,27 @@ 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 = { _, _ -> },
|
||||
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,
|
||||
@@ -241,6 +290,8 @@ 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,
|
||||
@@ -258,6 +309,8 @@ 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,
|
||||
@@ -275,6 +328,8 @@ 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,
|
||||
@@ -292,6 +347,8 @@ 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,
|
||||
|
||||
+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.effectiveMaxSelections,
|
||||
).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.effectiveMaxSelections,
|
||||
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 CreatePollStateProvider : 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 = {}
|
||||
)
|
||||
}
|
||||
|
||||
+63
@@ -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,18 @@ fun CreatePollView(
|
||||
item {
|
||||
Column {
|
||||
HorizontalDivider()
|
||||
ListItem(
|
||||
headlineContent = { Text(text = stringResource(id = R.string.screen_create_poll_max_selections_headline)) },
|
||||
supportingContent = { Text(text = stringResource(id = R.string.screen_create_poll_max_selections_desc)) },
|
||||
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(
|
||||
headlineContent = { 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 +246,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 = null,
|
||||
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 = null,
|
||||
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(
|
||||
|
||||
+17
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -44,13 +46,17 @@ data class PollFormState(
|
||||
* Create a copy of the [PollFormState] with a new blank answer added.
|
||||
*
|
||||
* If the maximum number of answers has already been reached an answer is not added.
|
||||
*
|
||||
* Also clamps [maxSelections] to the new answer count to keep state consistent.
|
||||
*/
|
||||
fun withNewAnswer(): PollFormState {
|
||||
if (!canAddAnswer) {
|
||||
return this
|
||||
}
|
||||
|
||||
return copy(answers = (answers + "").toImmutableList())
|
||||
val newAnswers = (answers + "").toImmutableList()
|
||||
val clampedMaxSelections = maxSelections.coerceIn(1, newAnswers.size)
|
||||
return copy(answers = newAnswers, maxSelections = clampedMaxSelections)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,7 +73,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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,6 +103,11 @@ data class PollFormState(
|
||||
*/
|
||||
val canDeleteAnswer get() = answers.size > MIN_ANSWERS
|
||||
|
||||
/**
|
||||
* The effective max selections, clamped to the number of answers.
|
||||
*/
|
||||
val effectiveMaxSelections get() = maxSelections.coerceIn(1, answers.size)
|
||||
|
||||
/**
|
||||
* Whether the form is currently valid.
|
||||
*/
|
||||
@@ -110,6 +123,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 +131,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 PollHistoryEvents {
|
||||
data object LoadMore : PollHistoryEvents
|
||||
data class SelectPollAnswer(val pollStartId: EventId, val answerId: String) : PollHistoryEvents
|
||||
data class SelectPollAnswer(val pollStartId: EventId, val answerIds: List<String>) : PollHistoryEvents
|
||||
data class EndPoll(val pollStartId: EventId) : PollHistoryEvents
|
||||
data class SelectFilter(val filter: PollHistoryFilter) : PollHistoryEvents
|
||||
}
|
||||
|
||||
+5
-2
@@ -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(
|
||||
@@ -72,8 +73,10 @@ class PollHistoryPresenter(
|
||||
sendPollResponseAction.execute(
|
||||
timeline = timeline,
|
||||
pollStartId = event.pollStartId,
|
||||
answerId = event.answerId
|
||||
)
|
||||
answerIds = event.answerIds
|
||||
).onFailure { error ->
|
||||
Timber.e(error, "Failed to send poll response")
|
||||
}
|
||||
}
|
||||
is PollHistoryEvents.EndPoll -> sessionCoroutineScope.launch {
|
||||
endPollAction.execute(timeline = timeline, pollStartId = event.pollStartId)
|
||||
|
||||
+4
-4
@@ -65,8 +65,8 @@ fun PollHistoryView(
|
||||
state.eventSink(PollHistoryEvents.LoadMore)
|
||||
}
|
||||
|
||||
fun onSelectAnswer(pollStartId: EventId, answerId: String) {
|
||||
state.eventSink(PollHistoryEvents.SelectPollAnswer(pollStartId, answerId))
|
||||
fun onSelectAnswer(pollStartId: EventId, answerIds: List<String>) {
|
||||
state.eventSink(PollHistoryEvents.SelectPollAnswer(pollStartId, answerIds))
|
||||
}
|
||||
|
||||
fun onEndPoll(pollStartId: EventId) {
|
||||
@@ -151,7 +151,7 @@ private fun PollHistoryList(
|
||||
pollHistoryItems: ImmutableList<PollHistoryItem>,
|
||||
hasMoreToLoad: Boolean,
|
||||
isLoading: Boolean,
|
||||
onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit,
|
||||
onSelectAnswer: (pollStartId: EventId, answerIds: List<String>) -> Unit,
|
||||
onEditPoll: (pollStartId: EventId) -> Unit,
|
||||
onEndPoll: (pollStartId: EventId) -> Unit,
|
||||
onLoadMore: () -> Unit,
|
||||
@@ -222,7 +222,7 @@ private fun LoadMoreButton(isLoading: Boolean, onClick: () -> Unit) {
|
||||
@Composable
|
||||
private fun PollHistoryItemRow(
|
||||
pollHistoryItem: PollHistoryItem,
|
||||
onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit,
|
||||
onSelectAnswer: (pollStartId: EventId, answerIds: List<String>) -> Unit,
|
||||
onEditPoll: (pollStartId: EventId) -> Unit,
|
||||
onEndPoll: (pollStartId: EventId) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
|
||||
+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,5 @@
|
||||
<?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">"Maximum selections"</string>
|
||||
<string name="screen_create_poll_max_selections_desc">"How many options each voter can select"</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(PollHistoryEvents.EndPoll(AN_EVENT_ID))
|
||||
runCurrent()
|
||||
endPollAction.verifyExecutionCount(1)
|
||||
state.eventSink(PollHistoryEvents.SelectPollAnswer(AN_EVENT_ID, "answer"))
|
||||
state.eventSink(PollHistoryEvents.SelectPollAnswer(AN_EVENT_ID, listOf("answer")))
|
||||
runCurrent()
|
||||
sendPollResponseAction.verifyExecutionCount(1)
|
||||
cancelAndConsumeRemainingEvents()
|
||||
|
||||
+1
-1
@@ -137,7 +137,7 @@ class PollHistoryViewTest {
|
||||
useUnmergedTree = true,
|
||||
).performClick()
|
||||
eventsRecorder.assertSingle(
|
||||
PollHistoryEvents.SelectPollAnswer(eventId, answer.id)
|
||||
PollHistoryEvents.SelectPollAnswer(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,
|
||||
|
||||
Reference in New Issue
Block a user