Merge pull request #1113 from vector-im/feature/fre/improve_poll_event_timeline_rendering

[Poll] Render ended poll and improve UI in the timeline
This commit is contained in:
Florian Renaud 2023-08-25 10:14:15 +02:00 committed by GitHub
commit 9999b95852
41 changed files with 495 additions and 247 deletions

1
changelog.d/1113.wip Normal file
View file

@ -0,0 +1 @@
[Polls] Improve UI and render ended state

View file

@ -21,7 +21,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContentProvider import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContentProvider
import io.element.android.features.poll.api.ActivePollContentView import io.element.android.features.poll.api.PollContentView
import io.element.android.libraries.designsystem.preview.DayNightPreviews import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.matrix.api.poll.PollAnswer import io.element.android.libraries.matrix.api.poll.PollAnswer
@ -33,10 +33,11 @@ fun TimelineItemPollView(
onAnswerSelected: (PollAnswer) -> Unit, onAnswerSelected: (PollAnswer) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
ActivePollContentView( PollContentView(
question = content.question, question = content.question,
answerItems = content.answerItems.toImmutableList(), answerItems = content.answerItems.toImmutableList(),
pollKind = content.pollKind, pollKind = content.pollKind,
isPollEnded = content.isEnded,
onAnswerSelected = onAnswerSelected, onAnswerSelected = onAnswerSelected,
modifier = modifier, modifier = modifier,
) )

View file

@ -23,7 +23,7 @@ import io.element.android.features.poll.api.PollAnswerItem
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.poll.PollKind import io.element.android.libraries.matrix.api.poll.isDisclosed
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import javax.inject.Inject import javax.inject.Inject
@ -36,18 +36,33 @@ class TimelineItemContentPollFactory @Inject constructor(
if (!featureFlagService.isFeatureEnabled(FeatureFlags.Polls)) return TimelineItemUnknownContent if (!featureFlagService.isFeatureEnabled(FeatureFlags.Polls)) return TimelineItemUnknownContent
// Todo Move this computation to the matrix rust sdk // Todo Move this computation to the matrix rust sdk
val showResults = content.kind == PollKind.Disclosed && matrixClient.sessionId in content.votes.flatMap { it.value } val totalVoteCount = content.votes.flatMap { it.value }.size
val pollVotesCount = content.votes.flatMap { it.value }.size val myVotes = content.votes.filter { matrixClient.sessionId in it.value }.keys
val userVotes = content.votes.filter { matrixClient.sessionId in it.value }.keys val isEndedPoll = content.endTime != null
val winnerIds = if (!isEndedPoll) {
emptyList()
} else {
content.answers
.map { answer -> answer.id }
.groupBy { answerId -> content.votes[answerId]?.size ?: 0 } // Group by votes count
.maxByOrNull { (votes, _) -> votes } // Keep max voted answers
?.takeIf { (votes, _) -> votes > 0 } // Ignore if no option has been voted
?.value
.orEmpty()
}
val answerItems = content.answers.map { answer -> val answerItems = content.answers.map { answer ->
val votesCount = content.votes[answer.id]?.size ?: 0 val answerVoteCount = content.votes[answer.id]?.size ?: 0
val progress = if (pollVotesCount > 0) votesCount.toFloat() / pollVotesCount.toFloat() else 0f val isSelected = answer.id in myVotes
val isWinner = answer.id in winnerIds
val percentage = if (totalVoteCount > 0) answerVoteCount.toFloat() / totalVoteCount.toFloat() else 0f
PollAnswerItem( PollAnswerItem(
answer = answer, answer = answer,
isSelected = answer.id in userVotes, isSelected = isSelected,
isDisclosed = showResults, isEnabled = !isEndedPoll,
votesCount = votesCount, isWinner = isWinner,
progress = progress, isDisclosed = content.kind.isDisclosed || isEndedPoll,
votesCount = answerVoteCount,
percentage = percentage,
) )
} }
@ -56,7 +71,7 @@ class TimelineItemContentPollFactory @Inject constructor(
answerItems = answerItems, answerItems = answerItems,
votes = content.votes, votes = content.votes,
pollKind = content.kind, pollKind = content.kind,
isDisclosed = showResults isEnded = isEndedPoll,
) )
} }
} }

View file

@ -25,7 +25,7 @@ data class TimelineItemPollContent(
val answerItems: List<PollAnswerItem>, val answerItems: List<PollAnswerItem>,
val votes: Map<String, List<UserId>>, val votes: Map<String, List<UserId>>,
val pollKind: PollKind, val pollKind: PollKind,
val isDisclosed: Boolean, val isEnded: Boolean,
) : TimelineItemEventContent { ) : TimelineItemEventContent {
override val type: String = "TimelineItemPollContent" override val type: String = "TimelineItemPollContent"
} }

View file

@ -24,16 +24,16 @@ open class TimelineItemPollContentProvider : PreviewParameterProvider<TimelineIt
override val values: Sequence<TimelineItemPollContent> override val values: Sequence<TimelineItemPollContent>
get() = sequenceOf( get() = sequenceOf(
aTimelineItemPollContent(), aTimelineItemPollContent(),
aTimelineItemPollContent().copy(isDisclosed = true), aTimelineItemPollContent().copy(pollKind = PollKind.Undisclosed),
) )
} }
fun aTimelineItemPollContent(): TimelineItemPollContent { fun aTimelineItemPollContent(): TimelineItemPollContent {
return TimelineItemPollContent( return TimelineItemPollContent(
pollKind = PollKind.Disclosed, pollKind = PollKind.Disclosed,
isDisclosed = false,
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(),
isEnded = false,
votes = emptyMap(), votes = emptyMap(),
) )
} }

View file

@ -27,8 +27,6 @@ dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.constraintlayout.compose)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)

View file

@ -1,118 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.poll.api
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BarChart
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.poll.PollAnswer
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
@Composable
fun ActivePollContentView(
question: String,
answerItems: ImmutableList<PollAnswerItem>,
pollKind: PollKind,
onAnswerSelected: (PollAnswer) -> Unit,
modifier: Modifier = Modifier,
) {
val showResults = answerItems.any { it.isSelected }
Column(
modifier = modifier
.selectableGroup()
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(imageVector = Icons.Default.BarChart, contentDescription = null)
Text(
text = question,
style = ElementTheme.typography.fontBodyLgMedium
)
}
answerItems.forEach { answerItem ->
PollAnswerView(
answerItem = answerItem,
onClick = { onAnswerSelected(answerItem.answer) }
)
}
val votesCount = answerItems.sumOf { it.votesCount }
when {
pollKind == PollKind.Undisclosed -> {
Text(
modifier = Modifier
.align(Alignment.Start)
.padding(start = 32.dp),
style = ElementTheme.typography.fontBodyXsRegular,
color = ElementTheme.colors.textSecondary,
text = stringResource(CommonStrings.common_poll_undisclosed_text),
)
}
showResults -> {
Text(
modifier = Modifier.align(Alignment.End),
style = ElementTheme.typography.fontBodyXsRegular,
color = ElementTheme.colors.textSecondary,
text = stringResource(CommonStrings.common_poll_total_votes, votesCount),
)
}
}
}
}
@DayNightPreviews
@Composable
internal fun ActivePollContentNoResultsPreview() = ElementPreview {
ActivePollContentView(
question = "What type of food should we have at the party?",
answerItems = aPollAnswerItemList(isDisclosed = false),
pollKind = PollKind.Undisclosed,
onAnswerSelected = { },
)
}
@DayNightPreviews
@Composable
internal fun ActivePollContentWithResultsPreview() = ElementPreview {
ActivePollContentView(
question = "What type of food should we have at the party?",
answerItems = aPollAnswerItemList(),
pollKind = PollKind.Disclosed,
onAnswerSelected = { },
)
}

View file

@ -23,14 +23,18 @@ import io.element.android.libraries.matrix.api.poll.PollAnswer
* *
* @property answer the poll answer. * @property answer the poll answer.
* @property isSelected whether the user has selected this answer. * @property isSelected whether the user has selected this answer.
* @property isEnabled whether the answer can be voted.
* @property isWinner whether this is the winner answer in the poll.
* @property isDisclosed whether the votes for this answer should be disclosed. * @property isDisclosed whether the votes for this answer should be disclosed.
* @property votesCount the number of votes for this answer. * @property votesCount the number of votes for this answer.
* @property progress the percentage of votes for this answer. * @property percentage the percentage of votes for this answer.
*/ */
data class PollAnswerItem( data class PollAnswerItem(
val answer: PollAnswer, val answer: PollAnswer,
val isSelected: Boolean, val isSelected: Boolean,
val isEnabled: Boolean,
val isWinner: Boolean,
val isDisclosed: Boolean, val isDisclosed: Boolean,
val votesCount: Int, val votesCount: Int,
val progress: Float, val percentage: Float,
) )

View file

@ -16,110 +16,166 @@
package io.element.android.features.poll.api package io.element.android.features.poll.api
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import androidx.constraintlayout.compose.Dimension import io.element.android.libraries.designsystem.theme.components.Icon
import androidx.constraintlayout.compose.Visibility import io.element.android.libraries.designsystem.theme.components.IconToggleButton
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
import io.element.android.libraries.designsystem.theme.components.RadioButton
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.toEnabledColor
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonPlurals import io.element.android.libraries.ui.strings.CommonPlurals
@Suppress("DestructuringDeclarationWithTooManyEntries") // This is necessary to declare the constraints ids
@Composable @Composable
fun PollAnswerView( fun PollAnswerView(
answerItem: PollAnswerItem, answerItem: PollAnswerItem,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
ConstraintLayout( Row(
modifier modifier
.wrapContentHeight()
.fillMaxWidth() .fillMaxWidth()
.selectable( .selectable(
selected = answerItem.isSelected, selected = answerItem.isSelected,
enabled = answerItem.isEnabled,
onClick = onClick, onClick = onClick,
role = Role.RadioButton, role = Role.RadioButton,
) )
) { ) {
val (radioButton, answerText, votesText, progressBar) = createRefs() IconToggleButton(
RadioButton( modifier = Modifier.size(22.dp),
modifier = Modifier.constrainAs(radioButton) { checked = answerItem.isSelected,
top.linkTo(answerText.top) enabled = answerItem.isEnabled,
bottom.linkTo(answerText.bottom) colors = IconButtonDefaults.iconToggleButtonColors(
start.linkTo(parent.start) contentColor = ElementTheme.colors.iconSecondary,
end.linkTo(answerText.start) checkedContentColor = ElementTheme.colors.iconPrimary,
}, disabledContentColor = ElementTheme.colors.iconDisabled,
selected = answerItem.isSelected,
onClick = null // null recommended for accessibility with screenreaders
)
Text(
modifier = Modifier.constrainAs(answerText) {
width = Dimension.fillToConstraints
top.linkTo(parent.top)
start.linkTo(radioButton.end, margin = 8.dp)
end.linkTo(votesText.start)
bottom.linkTo(progressBar.top)
},
text = answerItem.answer.text,
)
Text(
modifier = Modifier.constrainAs(votesText) {
start.linkTo(answerText.end)
end.linkTo(parent.end)
bottom.linkTo(answerText.bottom)
visibility = if (answerItem.isDisclosed) Visibility.Visible else Visibility.Gone
},
text = pluralStringResource(
id = CommonPlurals.common_poll_votes_count,
count = answerItem.votesCount,
answerItem.votesCount
), ),
style = ElementTheme.typography.fontBodySmRegular, onCheckedChange = { onClick() },
color = ElementTheme.colors.textSecondary, ) {
) Icon(
LinearProgressIndicator( imageVector = if (answerItem.isSelected) {
progress = answerItem.progress, Icons.Default.CheckCircle
modifier = Modifier } else {
.constrainAs(progressBar) { Icons.Default.RadioButtonUnchecked
start.linkTo(answerText.start)
end.linkTo(votesText.end)
top.linkTo(answerText.bottom, margin = 10.dp)
bottom.linkTo(parent.bottom)
width = Dimension.fillToConstraints
visibility = if (answerItem.isDisclosed) Visibility.Visible else Visibility.Gone
}, },
strokeCap = StrokeCap.Round, contentDescription = null,
) )
}
Spacer(modifier = Modifier.width(12.dp))
Column {
Row {
Text(
modifier = Modifier.weight(1f),
text = answerItem.answer.text,
style = if (answerItem.isWinner) ElementTheme.typography.fontBodyLgMedium else ElementTheme.typography.fontBodyLgRegular,
)
if (answerItem.isDisclosed) {
Text(
modifier = Modifier.align(Alignment.Bottom),
text = pluralStringResource(
id = CommonPlurals.common_poll_votes_count,
count = answerItem.votesCount,
answerItem.votesCount
),
style = if (answerItem.isWinner) ElementTheme.typography.fontBodySmMedium else ElementTheme.typography.fontBodySmRegular,
color = if (answerItem.isWinner) ElementTheme.colors.textPrimary else ElementTheme.colors.textSecondary,
)
}
}
Spacer(modifier = Modifier.height(10.dp))
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth(),
color = if (answerItem.isWinner) ElementTheme.colors.textSuccessPrimary else answerItem.isEnabled.toEnabledColor(),
progress = when {
answerItem.isDisclosed -> answerItem.percentage
answerItem.isSelected -> 1f
else -> 0f
},
strokeCap = StrokeCap.Round,
)
}
} }
} }
@DayNightPreviews @Preview
@Composable @Composable
internal fun PollAnswerViewNoResultsPreview() = ElementPreview { internal fun PollAnswerDisclosedNotSelectedPreview() = ElementThemedPreview {
PollAnswerView( PollAnswerView(
answerItem = aPollAnswerItem(), answerItem = aPollAnswerItem(isDisclosed = true, isSelected = false),
onClick = { }, onClick = { },
) )
} }
@DayNightPreviews @Preview
@Composable @Composable
internal fun PollAnswerViewWithResultPreview() = ElementPreview { internal fun PollAnswerDisclosedSelectedPreview() = ElementThemedPreview {
PollAnswerView( PollAnswerView(
answerItem = aPollAnswerItem(isDisclosed = true), answerItem = aPollAnswerItem(isDisclosed = true, isSelected = true),
onClick = { }
)
}
@Preview
@Composable
internal fun PollAnswerUndisclosedNotSelectedPreview() = ElementThemedPreview {
PollAnswerView(
answerItem = aPollAnswerItem(isDisclosed = false, isSelected = false),
onClick = { },
)
}
@Preview
@Composable
internal fun PollAnswerUndisclosedSelectedPreview() = ElementThemedPreview {
PollAnswerView(
answerItem = aPollAnswerItem(isDisclosed = false, isSelected = true),
onClick = { }
)
}
@Preview
@Composable
internal fun PollAnswerEndedWinnerNotSelectedPreview() = ElementThemedPreview {
PollAnswerView(
answerItem = aPollAnswerItem(isDisclosed = true, isSelected = false, isEnabled = false, isWinner = true),
onClick = { }
)
}
@Preview
@Composable
internal fun PollAnswerEndedWinnerSelectedPreview() = ElementThemedPreview {
PollAnswerView(
answerItem = aPollAnswerItem(isDisclosed = true, isSelected = true, isEnabled = false, isWinner = true),
onClick = { }
)
}
@Preview
@Composable
internal fun PollAnswerEndedSelectedPreview() = ElementThemedPreview {
PollAnswerView(
answerItem = aPollAnswerItem(isDisclosed = true, isSelected = true, isEnabled = false, isWinner = false),
onClick = { } onClick = { }
) )
} }

View file

@ -19,27 +19,33 @@ package io.element.android.features.poll.api
import io.element.android.libraries.matrix.api.poll.PollAnswer import io.element.android.libraries.matrix.api.poll.PollAnswer
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
fun aPollAnswerItemList(isDisclosed: Boolean = true) = persistentListOf( fun aPollAnswerItemList(isEnded: Boolean = false, isDisclosed: Boolean = true) = persistentListOf(
aPollAnswerItem( aPollAnswerItem(
answer = PollAnswer("option_1", "Italian \uD83C\uDDEE\uD83C\uDDF9"), answer = PollAnswer("option_1", "Italian \uD83C\uDDEE\uD83C\uDDF9"),
isDisclosed = isDisclosed, isDisclosed = isDisclosed,
isEnabled = !isEnded,
isWinner = isEnded,
votesCount = 5, votesCount = 5,
progress = 0.5f percentage = 0.5f
), ),
aPollAnswerItem( aPollAnswerItem(
answer = PollAnswer("option_2", "Chinese \uD83C\uDDE8\uD83C\uDDF3"), answer = PollAnswer("option_2", "Chinese \uD83C\uDDE8\uD83C\uDDF3"),
isDisclosed = isDisclosed, isDisclosed = isDisclosed,
isEnabled = !isEnded,
isWinner = false,
votesCount = 0, votesCount = 0,
progress = 0f percentage = 0f
), ),
aPollAnswerItem( aPollAnswerItem(
answer = PollAnswer("option_3", "Brazilian \uD83C\uDDE7\uD83C\uDDF7"), answer = PollAnswer("option_3", "Brazilian \uD83C\uDDE7\uD83C\uDDF7"),
isDisclosed = isDisclosed, isDisclosed = isDisclosed,
isEnabled = !isEnded,
isWinner = false,
isSelected = true, isSelected = true,
votesCount = 1, votesCount = 1,
progress = 0.1f percentage = 0.1f
), ),
aPollAnswerItem(isDisclosed = isDisclosed), aPollAnswerItem(isDisclosed = isDisclosed, isEnabled = !isEnded),
) )
fun aPollAnswerItem( fun aPollAnswerItem(
@ -48,13 +54,17 @@ fun aPollAnswerItem(
"French \uD83C\uDDEB\uD83C\uDDF7 But make it a very very very long option then this should just keep expanding" "French \uD83C\uDDEB\uD83C\uDDF7 But make it a very very very long option then this should just keep expanding"
), ),
isSelected: Boolean = false, isSelected: Boolean = false,
isEnabled: Boolean = true,
isWinner: Boolean = false,
isDisclosed: Boolean = true, isDisclosed: Boolean = true,
votesCount: Int = 4, votesCount: Int = 4,
progress: Float = 0.4f, percentage: Float = 0.4f,
) = PollAnswerItem( ) = PollAnswerItem(
answer = answer, answer = answer,
isSelected = isSelected, isSelected = isSelected,
isEnabled = isEnabled,
isWinner = isWinner,
isDisclosed = isDisclosed, isDisclosed = isDisclosed,
votesCount = votesCount, votesCount = votesCount,
progress = progress percentage = percentage
) )

View file

@ -0,0 +1,167 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.poll.api
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Poll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.poll.PollAnswer
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
@Composable
fun PollContentView(
question: String,
answerItems: ImmutableList<PollAnswerItem>,
pollKind: PollKind,
isPollEnded: Boolean,
onAnswerSelected: (PollAnswer) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.selectableGroup()
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
PollTitle(title = question)
PollAnswers(answerItems = answerItems, onAnswerSelected = onAnswerSelected)
when {
isPollEnded || pollKind == PollKind.Disclosed -> DisclosedPollBottomNotice(answerItems)
pollKind == PollKind.Undisclosed -> UndisclosedPollBottomNotice()
}
}
}
@Composable
internal fun PollTitle(
title: String,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
modifier = Modifier.size(22.dp),
imageVector = Icons.Outlined.Poll,
contentDescription = null
)
Text(
text = title,
style = ElementTheme.typography.fontBodyLgMedium
)
}
}
@Composable
internal fun PollAnswers(
answerItems: ImmutableList<PollAnswerItem>,
onAnswerSelected: (PollAnswer) -> Unit,
modifier: Modifier = Modifier,
) {
answerItems.forEach { answerItem ->
PollAnswerView(
modifier = modifier,
answerItem = answerItem,
onClick = { onAnswerSelected(answerItem.answer) }
)
}
}
@Composable
internal fun ColumnScope.DisclosedPollBottomNotice(
answerItems: ImmutableList<PollAnswerItem>,
modifier: Modifier = Modifier
) {
val votesCount = answerItems.sumOf { it.votesCount }
Text(
modifier = modifier.align(Alignment.End),
style = ElementTheme.typography.fontBodyXsRegular,
color = ElementTheme.colors.textSecondary,
text = stringResource(CommonStrings.common_poll_total_votes, votesCount),
)
}
@Composable
fun ColumnScope.UndisclosedPollBottomNotice(modifier: Modifier = Modifier) {
Text(
modifier = modifier
.align(Alignment.Start)
.padding(start = 34.dp),
style = ElementTheme.typography.fontBodyXsRegular,
color = ElementTheme.colors.textSecondary,
text = stringResource(CommonStrings.common_poll_undisclosed_text),
)
}
@DayNightPreviews
@Composable
internal fun PollContentUndisclosedPreview() = ElementPreview {
PollContentView(
question = "What type of food should we have at the party?",
answerItems = aPollAnswerItemList(isDisclosed = false),
pollKind = PollKind.Undisclosed,
isPollEnded = false,
onAnswerSelected = { },
)
}
@DayNightPreviews
@Composable
internal fun PollContentDisclosedPreview() = ElementPreview {
PollContentView(
question = "What type of food should we have at the party?",
answerItems = aPollAnswerItemList(),
pollKind = PollKind.Disclosed,
isPollEnded = false,
onAnswerSelected = { },
)
}
@DayNightPreviews
@Composable
internal fun PollContentEndedPreview() = ElementPreview {
PollContentView(
question = "What type of food should we have at the party?",
answerItems = aPollAnswerItemList(isEnded = true),
pollKind = PollKind.Disclosed,
isPollEnded = false,
onAnswerSelected = { },
)
}

View file

@ -0,0 +1,89 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.IconToggleButtonColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
@Composable
fun IconToggleButton(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
colors: IconToggleButtonColors = IconButtonDefaults.iconToggleButtonColors(),
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
content: @Composable () -> Unit
) {
androidx.compose.material3.IconToggleButton(
checked = checked,
onCheckedChange = onCheckedChange,
modifier = modifier,
enabled = enabled,
colors = colors,
interactionSource = interactionSource,
content = content,
)
}
@Preview(group = PreviewGroup.Toggles)
@Composable
internal fun IconToggleButtonPreview() = ElementThemedPreview(vertical = false) { ContentToPreview() }
@Composable
private fun ContentToPreview() {
var checked by remember { mutableStateOf(false) }
Column {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
val icon: @Composable () -> Unit = {
Icon(
imageVector = if (checked) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked,
contentDescription = "IconToggleButton"
)
}
IconToggleButton(checked = checked, enabled = true, onCheckedChange = { checked = !checked }, content = icon)
IconToggleButton(checked = checked, enabled = false, onCheckedChange = { checked = !checked }, content = icon)
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
val icon: @Composable () -> Unit = {
Icon(
imageVector = if (!checked) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked,
contentDescription = "IconToggleButton"
)
}
IconToggleButton(checked = !checked, enabled = true, onCheckedChange = { checked = !checked }, content = icon)
IconToggleButton(checked = !checked, enabled = false, onCheckedChange = { checked = !checked }, content = icon)
}
}
}

View file

@ -23,7 +23,10 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.material3.RadioButtonColors import androidx.compose.material3.RadioButtonColors
import androidx.compose.material3.RadioButtonDefaults import androidx.compose.material3.RadioButtonDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -67,14 +70,15 @@ internal fun RadioButtonPreview() = ElementThemedPreview(vertical = false) { Con
@Composable @Composable
private fun ContentToPreview() { private fun ContentToPreview() {
var checked by remember { mutableStateOf(false) }
Column { Column {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
RadioButton(selected = false, onClick = {}) RadioButton(selected = checked, enabled = true, onClick = { checked = !checked })
RadioButton(selected = false, enabled = false, onClick = {}) RadioButton(selected = checked, enabled = false, onClick = { checked = !checked })
} }
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
RadioButton(selected = true, onClick = {}) RadioButton(selected = !checked, enabled = true, onClick = { checked = !checked })
RadioButton(selected = true, enabled = false, onClick = {}) RadioButton(selected = !checked, enabled = false, onClick = { checked = !checked })
} }
} }
} }

View file

@ -95,7 +95,7 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
is StateContent -> { is StateContent -> {
stateContentFormatter.format(content, senderDisplayName, isOutgoing, RenderingMode.RoomList) stateContentFormatter.format(content, senderDisplayName, isOutgoing, RenderingMode.RoomList)
} }
is PollContent, is PollContent, // TODO Polls: handle last message
is FailedToParseMessageLikeContent, is FailedToParseStateContent, is UnknownContent -> { is FailedToParseMessageLikeContent, is FailedToParseStateContent, is UnknownContent -> {
prefixIfNeeded(sp.getString(CommonStrings.common_unsupported_event), senderDisplayName, isDmRoom) prefixIfNeeded(sp.getString(CommonStrings.common_unsupported_event), senderDisplayName, isDmRoom)
} }

View file

@ -20,5 +20,8 @@ enum class PollKind {
Disclosed, Disclosed,
/** Results should be only revealed when the poll is ended. */ /** Results should be only revealed when the poll is ended. */
Undisclosed Undisclosed,
} }
val PollKind.isDisclosed: Boolean
get() = this == PollKind.Disclosed

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f704348b48d03ce3e788e7e37298a008116c66f3ad0a074d164df4ebbb05d9d8 oid sha256:15d14bf99af3cd0433870ebd6032d9bf4a45196e2ef1df7184cc55859a704dee
size 48964 size 49008

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f704348b48d03ce3e788e7e37298a008116c66f3ad0a074d164df4ebbb05d9d8 oid sha256:6e47fc219bbd63b76d01a5e50c3e4c6b1b0a8b4ec40b08b13271d0b2673d8d5e
size 48964 size 50932

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:945d8f7b4226c451c8d33f51bf051c7f08a316c786259d49475b2e2cde407bd7 oid sha256:cacb91ffca97f21bbc19b29525813f58fc8017a858aa28ccd4e620b70a8cd9ca
size 46415 size 46119

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:945d8f7b4226c451c8d33f51bf051c7f08a316c786259d49475b2e2cde407bd7 oid sha256:3f1c14a23ee598ece6843a68d3ca0b1d1f725f53174bd493e27c6137de70c508
size 46415 size 48296

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fb9e4bbe341a84452206a7485a477d81725b535369b1dfad3cf430548dbb21e8
size 46450

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8bfbbae8e27c4be7ea7fadeff2470773775cb476ef37b25c8f1bb8c35b5eddd9
size 43082

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f704348b48d03ce3e788e7e37298a008116c66f3ad0a074d164df4ebbb05d9d8
size 48964

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:945d8f7b4226c451c8d33f51bf051c7f08a316c786259d49475b2e2cde407bd7
size 46415

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:74508ef7f77a9c8713c75586ae4d34a9daab2608dbbd2f20de3e4d4a9a9be7e9
size 39225

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4ac9e0523fc99d472a1fe8f21719e64dbb7d1ec01059dd4183aa4a152f8ead55
size 38673

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ed68d9ebe67d5dad938a3efcd8c2b680444c650e635bdc04cfd140ba694d9f1d
size 38928

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1d5c9d2042dad75b48b61cdbae5b2425d7c935eb860df5d5c6fa3bcb327d13d1
size 38842

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d4ab648f5651b7457635be3eabb914be1c976154d12a163f1c1bb2cd92168824
size 38730

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fbb713d36f8b36ce6b55f38c10323e784a80f6187e6613ded91dc531b23a7cb7
size 36444

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:31735c42ea83974595544a0f03636a2337210f23e60d4b7f8e41d46ba21d483f
size 35920

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c4c5bc5f2086bd78a364e9996f0f41526d73d69857875594f5de7ea9998333d7
size 23388

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:42848910a949ec938ccc7a3547b19d1c2c81193f9a93b1db21ca77e4d9ed9663
size 21761

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c4c5bc5f2086bd78a364e9996f0f41526d73d69857875594f5de7ea9998333d7
size 23388

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:42848910a949ec938ccc7a3547b19d1c2c81193f9a93b1db21ca77e4d9ed9663
size 21761

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:15d14bf99af3cd0433870ebd6032d9bf4a45196e2ef1df7184cc55859a704dee
size 49008

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cacb91ffca97f21bbc19b29525813f58fc8017a858aa28ccd4e620b70a8cd9ca
size 46119

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5a9ccbbc0a208ac398f07f0070a43d0ca5ab67ef322b274b641270a9c21a4a60
size 48972

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5736eb1d232b841d62f9d96070fc9b2993453652f5d7c448b6a819db9543615e
size 45756

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2264139761f7fe3977ece3c9a7d949ac51223dd209497b7b311987cba3e5a069
size 47154

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f3ff35998ce8558b5a7af84e378bee039e36099084b357fffccf329a4983b035
size 43551

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9f9b51e2b099380112683be07ce85679436fdaa7765cc72c200cbaca1e0b843e
size 13557