Update UI for poll in the timeline

This commit is contained in:
Florian Renaud 2023-08-17 14:16:22 +02:00
parent 3a476f5130
commit 00752d851a
7 changed files with 109 additions and 101 deletions

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,16 +36,15 @@ 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 pollVotesCount = content.votes.flatMap { it.value }.size val pollVotesCount = content.votes.flatMap { it.value }.size
val userVotes = content.votes.filter { matrixClient.sessionId in it.value }.keys val userVotes = content.votes.filter { matrixClient.sessionId in it.value }.keys
val answerItems = content.answers.map { answer -> val answerItems = content.answers.map { answer ->
val votesCount = content.votes[answer.id]?.size ?: 0 val votesCount = content.votes[answer.id]?.size ?: 0
val progress = if (pollVotesCount > 0) votesCount.toFloat() / pollVotesCount.toFloat() else 0f val progress = if (content.kind.isDisclosed && pollVotesCount > 0) votesCount.toFloat() / pollVotesCount.toFloat() else 0f
PollAnswerItem( PollAnswerItem(
answer = answer, answer = answer,
isSelected = answer.id in userVotes, isSelected = answer.id in userVotes,
isDisclosed = showResults, isDisclosed = content.kind.isDisclosed,
votesCount = votesCount, votesCount = votesCount,
progress = progress, progress = progress,
) )
@ -56,7 +55,6 @@ class TimelineItemContentPollFactory @Inject constructor(
answerItems = answerItems, answerItems = answerItems,
votes = content.votes, votes = content.votes,
pollKind = content.kind, pollKind = content.kind,
isDisclosed = showResults
) )
} }
} }

View file

@ -25,7 +25,6 @@ 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,
) : TimelineItemEventContent { ) : TimelineItemEventContent {
override val type: String = "TimelineItemPollContent" override val type: String = "TimelineItemPollContent"
} }

View file

@ -24,14 +24,13 @@ 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(),
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

@ -18,12 +18,14 @@ package io.element.android.features.poll.api
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BarChart import androidx.compose.material.icons.outlined.Poll
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -47,54 +49,80 @@ fun ActivePollContentView(
onAnswerSelected: (PollAnswer) -> Unit, onAnswerSelected: (PollAnswer) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val showResults = answerItems.any { it.isSelected }
Column( Column(
modifier = modifier modifier = modifier
.selectableGroup() .selectableGroup()
.fillMaxWidth(), .fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
Row( PollTitle(title = question)
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(imageVector = Icons.Default.BarChart, contentDescription = null)
Text(
text = question,
style = ElementTheme.typography.fontBodyLgMedium
)
}
answerItems.forEach { answerItem -> PollAnswers(answerItems = answerItems, onAnswerSelected = onAnswerSelected)
PollAnswerView(
answerItem = answerItem,
onClick = { onAnswerSelected(answerItem.answer) }
)
}
val votesCount = answerItems.sumOf { it.votesCount } when (pollKind) {
when { PollKind.Disclosed -> DisclosedPollBottomNotice(answerItems)
pollKind == PollKind.Undisclosed -> { PollKind.Undisclosed -> UndisclosedPollBottomNotice()
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),
)
}
} }
} }
} }
@Composable
internal fun PollTitle(
title: String,
) {
Row(
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,
) {
answerItems.forEach { answerItem ->
PollAnswerView(
answerItem = answerItem,
onClick = { onAnswerSelected(answerItem.answer) }
)
}
}
@Composable
internal fun ColumnScope.DisclosedPollBottomNotice(
answerItems: ImmutableList<PollAnswerItem>,
) {
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() {
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 @DayNightPreviews
@Composable @Composable
internal fun ActivePollContentNoResultsPreview() = ElementPreview { internal fun ActivePollContentNoResultsPreview() = ElementPreview {

View file

@ -16,18 +16,21 @@
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.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.unit.dp import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension
import androidx.constraintlayout.compose.Visibility
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.designsystem.theme.components.LinearProgressIndicator import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
@ -36,16 +39,14 @@ import io.element.android.libraries.designsystem.theme.components.Text
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,
@ -53,56 +54,38 @@ fun PollAnswerView(
role = Role.RadioButton, role = Role.RadioButton,
) )
) { ) {
val (radioButton, answerText, votesText, progressBar) = createRefs()
RadioButton( RadioButton(
modifier = Modifier.constrainAs(radioButton) { modifier = Modifier.size(22.dp),
top.linkTo(answerText.top)
bottom.linkTo(answerText.bottom)
start.linkTo(parent.start)
end.linkTo(answerText.start)
},
selected = answerItem.isSelected, selected = answerItem.isSelected,
onClick = null // null recommended for accessibility with screenreaders onClick = null // null recommended for accessibility with screenreaders
) )
Text( Spacer(modifier = Modifier.width(12.dp))
modifier = Modifier.constrainAs(answerText) { Column {
width = Dimension.fillToConstraints Row {
top.linkTo(parent.top) Text(
start.linkTo(radioButton.end, margin = 8.dp) modifier = Modifier.weight(1f),
end.linkTo(votesText.start) text = answerItem.answer.text
bottom.linkTo(progressBar.top) )
}, if (answerItem.isDisclosed) {
text = answerItem.answer.text, Text(
) modifier = Modifier.align(Alignment.Bottom),
Text( text = pluralStringResource(
modifier = Modifier.constrainAs(votesText) { id = CommonPlurals.common_poll_votes_count,
start.linkTo(answerText.end) count = answerItem.votesCount,
end.linkTo(parent.end) answerItem.votesCount
bottom.linkTo(answerText.bottom) ),
visibility = if (answerItem.isDisclosed) Visibility.Visible else Visibility.Gone style = ElementTheme.typography.fontBodySmRegular,
}, color = ElementTheme.colors.textSecondary,
text = pluralStringResource( )
id = CommonPlurals.common_poll_votes_count, }
count = answerItem.votesCount, }
answerItem.votesCount Spacer(modifier = Modifier.height(10.dp))
), LinearProgressIndicator(
style = ElementTheme.typography.fontBodySmRegular, modifier = Modifier.fillMaxWidth(),
color = ElementTheme.colors.textSecondary, progress = answerItem.progress,
) strokeCap = StrokeCap.Round,
LinearProgressIndicator( )
progress = answerItem.progress, }
modifier = Modifier
.constrainAs(progressBar) {
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,
)
} }
} }

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