From 8f2db603509de23b6e66bfee66c30dad85a1f0c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 22 May 2026 11:24:53 +0100 Subject: [PATCH 1/4] Improve poll results TalkBack accessibility --- .../ui/components/poll/PollDialogHeader.kt | 11 +++++- .../components/poll/PollViewResultDialog.kt | 34 +++++++++++-------- .../ui/components/poll/PollVoteItem.kt | 3 +- 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollDialogHeader.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollDialogHeader.kt index 3078b203d36..433cfaf0535 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollDialogHeader.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollDialogHeader.kt @@ -23,10 +23,14 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import io.getstream.chat.android.compose.R @@ -40,6 +44,9 @@ public fun PollDialogHeader( onBackPressed: () -> Unit, trailingContent: @Composable () -> Unit = {}, ) { + val view = LocalView.current + LaunchedEffect(title) { view.announceForAccessibility(title) } + Row( modifier = Modifier .fillMaxWidth() @@ -52,7 +59,9 @@ public fun PollDialogHeader( ) Text( - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .semantics { heading() }, text = title, style = ChatTheme.typography.headingMedium, maxLines = 1, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollViewResultDialog.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollViewResultDialog.kt index 77f5738dec9..be340f77c64 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollViewResultDialog.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollViewResultDialog.kt @@ -55,6 +55,8 @@ import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -204,21 +206,23 @@ private fun PollViewResultItem( modifier: Modifier, ) { PollSection(modifier) { - Text( - modifier = Modifier - .fillMaxWidth() - .padding( - start = StreamTokens.spacingMd, - end = StreamTokens.spacingMd, - top = StreamTokens.spacingMd, - bottom = StreamTokens.spacing2xs, - ), - text = stringResource(R.string.stream_compose_poll_option_label, index + 1), - color = ChatTheme.colors.textTertiary, - style = ChatTheme.typography.headingExtraSmall, - ) + Column(modifier = Modifier.semantics(mergeDescendants = true) { heading() }) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding( + start = StreamTokens.spacingMd, + end = StreamTokens.spacingMd, + top = StreamTokens.spacingMd, + bottom = StreamTokens.spacing2xs, + ), + text = stringResource(R.string.stream_compose_poll_option_label, index + 1), + color = ChatTheme.colors.textTertiary, + style = ChatTheme.typography.headingExtraSmall, + ) - PollResultOptionInfo(item) + PollResultOptionInfo(item) + } item.votes.forEach { vote -> PollVoteItem( @@ -315,7 +319,7 @@ private fun PollViewResultTitle( modifier: Modifier, ) { PollSection( - modifier = modifier, + modifier = modifier.semantics(mergeDescendants = true) { heading() }, verticalArrangement = Arrangement.spacedBy(StreamTokens.spacingXs), contentPadding = PaddingValues(StreamTokens.spacingMd), ) { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollVoteItem.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollVoteItem.kt index fa2e4896cb4..73066dbfad0 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollVoteItem.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollVoteItem.kt @@ -26,6 +26,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.getstream.chat.android.compose.ui.components.avatar.AvatarSize @@ -43,7 +44,7 @@ internal fun PollVoteItem( val borderSize = 2.dp Row( - modifier = modifier, + modifier = modifier.semantics(mergeDescendants = true) {}, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(StreamTokens.spacingSm), ) { From 2a73eb4639c50577e388788c42e77d29424eabcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 22 May 2026 11:40:35 +0100 Subject: [PATCH 2/4] Improve poll answers and more-options dialog TalkBack accessibility --- .../compose/ui/components/poll/PollAnswers.kt | 62 ++++++++++--------- .../components/poll/PollMoreOptionsDialog.kt | 54 ++++++++++++---- 2 files changed, 76 insertions(+), 40 deletions(-) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollAnswers.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollAnswers.kt index 7ae00621940..6de779320f9 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollAnswers.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollAnswers.kt @@ -56,6 +56,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup @@ -206,40 +207,45 @@ internal fun PollAnswersItem( contentPadding = PaddingValues(StreamTokens.spacingMd), verticalArrangement = Arrangement.spacedBy(StreamTokens.spacingXs), ) { - Text( - text = answer.text, - color = colors.textPrimary, - style = typography.bodyDefault, - ) - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(StreamTokens.spacingXs), + Column( + modifier = Modifier.semantics(mergeDescendants = true) {}, + verticalArrangement = Arrangement.spacedBy(StreamTokens.spacingXs), ) { - val user = answer.user?.takeIf { showAvatar } - if (user != null) { - ChatTheme.componentFactory.UserAvatar( - params = UserAvatarParams( - modifier = Modifier.size(AvatarSize.ExtraSmall), - user = user, - showIndicator = false, - showBorder = false, - ), - ) + Text( + text = answer.text, + color = colors.textPrimary, + style = typography.bodyDefault, + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(StreamTokens.spacingXs), + ) { + val user = answer.user?.takeIf { showAvatar } + if (user != null) { + ChatTheme.componentFactory.UserAvatar( + params = UserAvatarParams( + modifier = Modifier.size(AvatarSize.ExtraSmall), + user = user, + showIndicator = false, + showBorder = false, + ), + ) + + Text( + modifier = Modifier.weight(1f), + text = user.name, + color = colors.chatTextUsername, + style = typography.captionDefault, + ) + } Text( - modifier = Modifier.weight(1f), - text = user.name, - color = colors.chatTextUsername, + text = ChatTheme.dateFormatter.formatDate(answer.createdAt), + color = colors.textTertiary, style = typography.captionDefault, ) } - - Text( - text = ChatTheme.dateFormatter.formatDate(answer.createdAt), - color = colors.textTertiary, - style = typography.captionDefault, - ) } if (showUpdateButton) { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollMoreOptionsDialog.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollMoreOptionsDialog.kt index a86846337ea..2fd6b1e6ec9 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollMoreOptionsDialog.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollMoreOptionsDialog.kt @@ -36,6 +36,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.LinearProgressIndicator @@ -48,7 +49,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.hideFromAccessibility +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -60,6 +68,7 @@ import io.getstream.chat.android.compose.ui.components.avatar.UserAvatarStack import io.getstream.chat.android.compose.ui.components.common.RadioCheck import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.ui.theme.StreamTokens +import io.getstream.chat.android.compose.ui.util.applyIf import io.getstream.chat.android.compose.viewmodel.messages.MessageListViewModel import io.getstream.chat.android.models.Option import io.getstream.chat.android.models.Poll @@ -190,7 +199,9 @@ private fun Content( @Composable internal fun PollMoreOptionsTitle(title: String) { PollSection( - modifier = Modifier.padding(vertical = StreamTokens.spacing2xl), + modifier = Modifier + .padding(vertical = StreamTokens.spacing2xl) + .semantics(mergeDescendants = true) { heading() }, verticalArrangement = Arrangement.spacedBy(StreamTokens.spacingXs), contentPadding = PaddingValues(StreamTokens.spacingMd), ) { @@ -259,25 +270,36 @@ private fun PollMoreOptionItem( ) { val colors = ChatTheme.colors val typography = ChatTheme.typography + val toggleRole = if (poll.maxVotesAllowed == 1) Role.RadioButton else Role.Checkbox + val onToggle: (Boolean) -> Unit = { enabled -> + val canVote = poll.maxVotesAllowed?.let { checkedCount < it } ?: true + if (enabled && canVote && !checked) { + onCastVote.invoke() + } else if (!enabled) { + onRemoveVote.invoke() + } + } Row( modifier = Modifier .fillMaxWidth() - .padding(StreamTokens.spacingXs), + .padding(StreamTokens.spacingXs) + .applyIf(!poll.closed) { + toggleable( + value = checked, + role = toggleRole, + onValueChange = onToggle, + ) + } + .semantics(mergeDescendants = true) {}, horizontalArrangement = Arrangement.spacedBy(StreamTokens.spacingSm), verticalAlignment = Alignment.CenterVertically, ) { if (!poll.closed) { RadioCheck( + modifier = Modifier.semantics { hideFromAccessibility() }, checked = checked, - onCheckedChange = { enabled -> - val canVote = poll.maxVotesAllowed?.let { checkedCount < it } ?: true - if (enabled && canVote && !checked) { - onCastVote.invoke() - } else if (!enabled) { - onRemoveVote.invoke() - } - }, + onCheckedChange = onToggle, borderColor = colors.chatBorderOnChatIncoming, ) } @@ -302,8 +324,15 @@ private fun PollMoreOptionItem( ) } + val voteCountDescription = pluralStringResource( + R.plurals.stream_compose_poll_vote_counts, + voteCount, + voteCount, + ) Text( - modifier = Modifier.align(Alignment.CenterVertically), + modifier = Modifier + .align(Alignment.CenterVertically) + .semantics { contentDescription = voteCountDescription }, text = voteCount.toString(), style = typography.metadataDefault, color = colors.chatTextIncoming, @@ -322,7 +351,8 @@ private fun PollMoreOptionItem( modifier = Modifier .fillMaxWidth() .height(8.dp) - .clip(RoundedCornerShape(4.dp)), + .clip(RoundedCornerShape(4.dp)) + .clearAndSetSemantics {}, progress = { progress }, color = colors.chatPollProgressFillIncoming, trackColor = colors.chatPollProgressTrackIncoming, From c4ec0e568c42fb1e0d1c8bc01df82b5ac9423747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 22 May 2026 13:10:58 +0100 Subject: [PATCH 3/4] Improve poll option votes dialog TalkBack accessibility --- .../compose/ui/components/poll/PollOptionVotesDialog.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollOptionVotesDialog.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollOptionVotesDialog.kt index 1b5d0c6ea66..04d709feb31 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollOptionVotesDialog.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollOptionVotesDialog.kt @@ -48,6 +48,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -174,7 +175,9 @@ private fun Content( ) { item { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) {}, horizontalArrangement = Arrangement.End, ) { if (state.isWinner) { From afecec43756462d84296354ab807dda25888bf2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 22 May 2026 14:06:01 +0100 Subject: [PATCH 4/4] Extract shared poll option voting row --- .../components/messages/PollMessageContent.kt | 131 ++---------- .../components/poll/PollMoreOptionsDialog.kt | 138 ++----------- .../ui/components/poll/PollOptionVotingRow.kt | 192 ++++++++++++++++++ 3 files changed, 227 insertions(+), 234 deletions(-) create mode 100644 stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollOptionVotingRow.kt diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/PollMessageContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/PollMessageContent.kt index db12bb80125..c3b8859bdcb 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/PollMessageContent.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/PollMessageContent.kt @@ -16,66 +16,47 @@ package io.getstream.chat.android.compose.ui.components.messages -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.selection.toggleable -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.clearAndSetSemantics -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.hideFromAccessibility -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.getstream.chat.android.compose.R -import io.getstream.chat.android.compose.ui.components.avatar.AvatarSize -import io.getstream.chat.android.compose.ui.components.avatar.UserAvatarStack import io.getstream.chat.android.compose.ui.components.button.StreamButtonSize import io.getstream.chat.android.compose.ui.components.button.StreamButtonStyle import io.getstream.chat.android.compose.ui.components.button.StreamButtonStyleDefaults import io.getstream.chat.android.compose.ui.components.button.StreamTextButton -import io.getstream.chat.android.compose.ui.components.common.RadioCheck import io.getstream.chat.android.compose.ui.components.composer.InputField import io.getstream.chat.android.compose.ui.components.poll.AddAnswerDialog +import io.getstream.chat.android.compose.ui.components.poll.PollOptionVotingRow import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.ui.theme.MessageBubbleParams import io.getstream.chat.android.compose.ui.theme.MessageFailedIconParams import io.getstream.chat.android.compose.ui.theme.MessageStyling import io.getstream.chat.android.compose.ui.theme.MessageStyling.PollStyle import io.getstream.chat.android.compose.ui.theme.StreamTokens -import io.getstream.chat.android.compose.ui.util.applyIf import io.getstream.chat.android.compose.ui.util.isErrorOrFailed import io.getstream.chat.android.compose.ui.util.passiveRipple import io.getstream.chat.android.compose.util.extensions.toSet @@ -85,7 +66,6 @@ import io.getstream.chat.android.models.Option import io.getstream.chat.android.models.Poll import io.getstream.chat.android.models.User import io.getstream.chat.android.models.Vote -import io.getstream.chat.android.models.VotingVisibility import io.getstream.chat.android.previewdata.PreviewMessageData import io.getstream.chat.android.ui.common.state.messages.list.MessageItemState import io.getstream.chat.android.ui.common.state.messages.poll.PollSelectionType @@ -418,7 +398,7 @@ private fun NewOptionDialog( ) } -@Suppress("LongParameterList", "LongMethod") +@Suppress("LongParameterList") @Composable private fun PollOptionItem( modifier: Modifier = Modifier, @@ -433,98 +413,19 @@ private fun PollOptionItem( onCastVote: () -> Unit, onRemoveVote: () -> Unit, ) { - val typography = ChatTheme.typography - val toggleRole = if (poll.maxVotesAllowed == 1) Role.RadioButton else Role.Checkbox - val onToggle: (Boolean) -> Unit = { enabled -> - val canVote = poll.maxVotesAllowed?.let { checkedCount < it } ?: true - if (enabled && canVote && !checked) { - onCastVote.invoke() - } else if (!enabled) { - onRemoveVote.invoke() - } - } - - Row( - modifier = modifier - .fillMaxWidth() - .applyIf(!poll.closed) { - toggleable( - value = checked, - role = toggleRole, - onValueChange = onToggle, - ) - } - .semantics(mergeDescendants = true) {}, - horizontalArrangement = Arrangement.spacedBy(StreamTokens.spacingSm), - verticalAlignment = Alignment.CenterVertically, - ) { - if (!poll.closed) { - RadioCheck( - modifier = Modifier.semantics { hideFromAccessibility() }, - checked = checked, - onCheckedChange = onToggle, - borderColor = style.outlineColor, - ) - } - - Column(verticalArrangement = Arrangement.spacedBy(StreamTokens.spacing2xs)) { - Row(Modifier.heightIn(min = AvatarSize.ExtraSmall)) { - Text( - modifier = Modifier.weight(1f), - text = option.text, - style = typography.captionDefault, - color = style.textColor, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - - if (users.isNotEmpty() && poll.votingVisibility != VotingVisibility.ANONYMOUS) { - UserAvatarStack( - overlap = StreamTokens.spacingXs, - users = users.take(MaxStackedAvatars), - avatarSize = AvatarSize.ExtraSmall, - modifier = Modifier.padding(start = StreamTokens.spacingXs, end = StreamTokens.spacing2xs), - ) - } - - val voteCountDescription = pluralStringResource( - R.plurals.stream_compose_poll_vote_counts, - voteCount, - voteCount, - ) - Text( - modifier = Modifier - .align(Alignment.CenterVertically) - .semantics { contentDescription = voteCountDescription }, - text = voteCount.toString(), - style = typography.metadataDefault, - color = style.textColor, - ) - } - - val progress by animateFloatAsState( - targetValue = if (voteCount == 0 || totalVoteCount == 0) { - 0f - } else { - voteCount / totalVoteCount.toFloat() - }, - ) - - LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .height(8.dp) - .clip(RoundedCornerShape(4.dp)) - .clearAndSetSemantics {}, - progress = { progress }, - color = style.progressColor, - trackColor = style.trackColor, - gapSize = 0.dp, - strokeCap = StrokeCap.Square, - drawStopIndicator = { /* Don't draw the stop indicator */ }, - ) - } - } + PollOptionVotingRow( + modifier = modifier, + poll = poll, + option = option, + voteCount = voteCount, + totalVoteCount = totalVoteCount, + users = users, + checkedCount = checkedCount, + checked = checked, + style = style, + onCastVote = onCastVote, + onRemoveVote = onRemoveVote, + ) } @Composable @@ -568,8 +469,6 @@ private fun EndPollConfirmationDialog( ) } -private const val MaxStackedAvatars = 3 - @Composable private fun PollOptionButton( text: String, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollMoreOptionsDialog.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollMoreOptionsDialog.kt index 2fd6b1e6ec9..7f603588ae1 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollMoreOptionsDialog.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollMoreOptionsDialog.kt @@ -19,7 +19,6 @@ package io.getstream.chat.android.compose.ui.components.poll import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -29,52 +28,32 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.selection.toggleable -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.clearAndSetSemantics -import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.heading -import androidx.compose.ui.semantics.hideFromAccessibility import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import io.getstream.chat.android.compose.R -import io.getstream.chat.android.compose.ui.components.avatar.AvatarSize -import io.getstream.chat.android.compose.ui.components.avatar.UserAvatarStack -import io.getstream.chat.android.compose.ui.components.common.RadioCheck import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.compose.ui.theme.MessageStyling.PollStyle import io.getstream.chat.android.compose.ui.theme.StreamTokens -import io.getstream.chat.android.compose.ui.util.applyIf import io.getstream.chat.android.compose.viewmodel.messages.MessageListViewModel import io.getstream.chat.android.models.Option import io.getstream.chat.android.models.Poll import io.getstream.chat.android.models.User import io.getstream.chat.android.models.Vote -import io.getstream.chat.android.models.VotingVisibility import io.getstream.chat.android.previewdata.PreviewMessageData import io.getstream.chat.android.previewdata.PreviewPollData import io.getstream.chat.android.ui.common.state.messages.poll.PollSelectionType @@ -255,7 +234,7 @@ private fun PollMoreOptionsItemList( } } -@Suppress("LongParameterList", "LongMethod") +@Suppress("LongParameterList") @Composable private fun PollMoreOptionItem( poll: Poll, @@ -269,103 +248,26 @@ private fun PollMoreOptionItem( onRemoveVote: () -> Unit, ) { val colors = ChatTheme.colors - val typography = ChatTheme.typography - val toggleRole = if (poll.maxVotesAllowed == 1) Role.RadioButton else Role.Checkbox - val onToggle: (Boolean) -> Unit = { enabled -> - val canVote = poll.maxVotesAllowed?.let { checkedCount < it } ?: true - if (enabled && canVote && !checked) { - onCastVote.invoke() - } else if (!enabled) { - onRemoveVote.invoke() - } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(StreamTokens.spacingXs) - .applyIf(!poll.closed) { - toggleable( - value = checked, - role = toggleRole, - onValueChange = onToggle, - ) - } - .semantics(mergeDescendants = true) {}, - horizontalArrangement = Arrangement.spacedBy(StreamTokens.spacingSm), - verticalAlignment = Alignment.CenterVertically, - ) { - if (!poll.closed) { - RadioCheck( - modifier = Modifier.semantics { hideFromAccessibility() }, - checked = checked, - onCheckedChange = onToggle, - borderColor = colors.chatBorderOnChatIncoming, - ) - } - - Column(verticalArrangement = Arrangement.spacedBy(StreamTokens.spacing2xs)) { - Row(Modifier.heightIn(min = AvatarSize.ExtraSmall)) { - Text( - modifier = Modifier.weight(1f), - text = option.text, - style = typography.captionDefault, - color = colors.chatTextIncoming, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - - if (users.isNotEmpty() && poll.votingVisibility != VotingVisibility.ANONYMOUS) { - UserAvatarStack( - overlap = StreamTokens.spacingXs, - users = users.take(MaxStackedAvatars), - avatarSize = AvatarSize.ExtraSmall, - modifier = Modifier.padding(start = StreamTokens.spacingXs, end = StreamTokens.spacing2xs), - ) - } - - val voteCountDescription = pluralStringResource( - R.plurals.stream_compose_poll_vote_counts, - voteCount, - voteCount, - ) - Text( - modifier = Modifier - .align(Alignment.CenterVertically) - .semantics { contentDescription = voteCountDescription }, - text = voteCount.toString(), - style = typography.metadataDefault, - color = colors.chatTextIncoming, - ) - } - - val progress by animateFloatAsState( - targetValue = if (voteCount == 0 || totalVoteCount == 0) { - 0f - } else { - voteCount / totalVoteCount.toFloat() - }, - ) - - LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .height(8.dp) - .clip(RoundedCornerShape(4.dp)) - .clearAndSetSemantics {}, - progress = { progress }, - color = colors.chatPollProgressFillIncoming, - trackColor = colors.chatPollProgressTrackIncoming, - gapSize = 0.dp, - strokeCap = StrokeCap.Square, - drawStopIndicator = { /* Don't draw the stop indicator */ }, - ) - } - } + PollOptionVotingRow( + modifier = Modifier.padding(StreamTokens.spacingXs), + poll = poll, + option = option, + voteCount = voteCount, + totalVoteCount = totalVoteCount, + users = users, + checkedCount = checkedCount, + checked = checked, + style = PollStyle( + textColor = colors.chatTextIncoming, + outlineColor = colors.chatBorderOnChatIncoming, + progressColor = colors.chatPollProgressFillIncoming, + trackColor = colors.chatPollProgressTrackIncoming, + ), + onCastVote = onCastVote, + onRemoveVote = onRemoveVote, + ) } -private const val MaxStackedAvatars = 3 - @Preview @Composable private fun PollMoreOptionsDialogPreview() { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollOptionVotingRow.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollOptionVotingRow.kt new file mode 100644 index 00000000000..a409f1fcc9e --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/poll/PollOptionVotingRow.kt @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * 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.getstream.chat.android.compose.ui.components.poll + +import androidx.compose.animation.core.animateFloatAsState +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.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.hideFromAccessibility +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.getstream.chat.android.compose.R +import io.getstream.chat.android.compose.ui.components.avatar.AvatarSize +import io.getstream.chat.android.compose.ui.components.avatar.UserAvatarStack +import io.getstream.chat.android.compose.ui.components.common.RadioCheck +import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.compose.ui.theme.MessageStyling.PollStyle +import io.getstream.chat.android.compose.ui.theme.StreamTokens +import io.getstream.chat.android.compose.ui.util.applyIf +import io.getstream.chat.android.models.Option +import io.getstream.chat.android.models.Poll +import io.getstream.chat.android.models.User +import io.getstream.chat.android.models.VotingVisibility + +/** + * Renders a single poll option as a voting row: a leading radio / checkbox, the option text, a + * preview of voter avatars, the vote count, and a progress bar. + * + * The whole row is a single TalkBack focus exposing the [Role.RadioButton] or [Role.Checkbox] + * role (depending on [Poll.maxVotesAllowed]) with the option text and vote count. Toggling fires + * [onCastVote] or [onRemoveVote] following the same rules as the inline poll on the message + * screen. + * + * Shared by the inline poll on the message screen and the more-options bottom sheet — both + * delegate here so the a11y wiring is implemented once. + * + * @param poll The poll the option belongs to (drives role + cast/remove gating via + * [Poll.maxVotesAllowed] and [Poll.closed]). + * @param option The option rendered by this row. + * @param voteCount Number of votes the option has received. + * @param totalVoteCount Total votes across all options, used to compute the progress fill. + * @param users Subset of users whose avatars are previewed when voting visibility is public. + * @param checkedCount Number of options the current user has already voted for, used together + * with [Poll.maxVotesAllowed] to decide whether another vote can be cast. + * @param checked Whether the current user has voted for this option. + * @param style Colours used for text, radio outline, progress fill and track. + * @param onCastVote Invoked when the user casts a vote for this option. + * @param onRemoveVote Invoked when the user removes their vote from this option. + * @param modifier Modifier applied to the row container. + */ +@Suppress("LongParameterList", "LongMethod") +@Composable +internal fun PollOptionVotingRow( + poll: Poll, + option: Option, + voteCount: Int, + totalVoteCount: Int, + users: List, + checkedCount: Int, + checked: Boolean, + style: PollStyle, + onCastVote: () -> Unit, + onRemoveVote: () -> Unit, + modifier: Modifier = Modifier, +) { + val toggleRole = if (poll.maxVotesAllowed == 1) Role.RadioButton else Role.Checkbox + val onToggle: (Boolean) -> Unit = { enabled -> + val canVote = poll.maxVotesAllowed?.let { checkedCount < it } ?: true + if (enabled && canVote && !checked) { + onCastVote() + } else if (!enabled) { + onRemoveVote() + } + } + + Row( + modifier = modifier + .fillMaxWidth() + .applyIf(!poll.closed) { + toggleable( + value = checked, + role = toggleRole, + onValueChange = onToggle, + ) + } + .semantics(mergeDescendants = true) {}, + horizontalArrangement = Arrangement.spacedBy(StreamTokens.spacingSm), + verticalAlignment = Alignment.CenterVertically, + ) { + if (!poll.closed) { + RadioCheck( + modifier = Modifier.semantics { hideFromAccessibility() }, + checked = checked, + onCheckedChange = onToggle, + borderColor = style.outlineColor, + ) + } + + Column(verticalArrangement = Arrangement.spacedBy(StreamTokens.spacing2xs)) { + Row(Modifier.heightIn(min = AvatarSize.ExtraSmall)) { + Text( + modifier = Modifier.weight(1f), + text = option.text, + style = ChatTheme.typography.captionDefault, + color = style.textColor, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + if (users.isNotEmpty() && poll.votingVisibility != VotingVisibility.ANONYMOUS) { + UserAvatarStack( + overlap = StreamTokens.spacingXs, + users = users.take(MaxStackedAvatars), + avatarSize = AvatarSize.ExtraSmall, + modifier = Modifier.padding(start = StreamTokens.spacingXs, end = StreamTokens.spacing2xs), + ) + } + + val voteCountDescription = pluralStringResource( + R.plurals.stream_compose_poll_vote_counts, + voteCount, + voteCount, + ) + Text( + modifier = Modifier + .align(Alignment.CenterVertically) + .semantics { contentDescription = voteCountDescription }, + text = voteCount.toString(), + style = ChatTheme.typography.metadataDefault, + color = style.textColor, + ) + } + + val progress by animateFloatAsState( + targetValue = if (voteCount == 0 || totalVoteCount == 0) { + 0f + } else { + voteCount / totalVoteCount.toFloat() + }, + ) + + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(4.dp)) + .clearAndSetSemantics {}, + progress = { progress }, + color = style.progressColor, + trackColor = style.trackColor, + gapSize = 0.dp, + strokeCap = StrokeCap.Square, + drawStopIndicator = { /* Don't draw the stop indicator */ }, + ) + } + } +} + +private const val MaxStackedAvatars = 3