diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/avatar/AvatarStack.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/avatar/AvatarStack.kt index ded9d15ba2f..fd5ea8ce7c0 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/avatar/AvatarStack.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/avatar/AvatarStack.kt @@ -33,6 +33,12 @@ import io.getstream.chat.android.compose.ui.theme.UserAvatarParams import io.getstream.chat.android.models.User import io.getstream.chat.android.previewdata.PreviewUserData +/** + * Width of the border drawn around each avatar in a [UserAvatarStack]. Each avatar therefore + * occupies `avatarSize + AvatarStackBorderSize` in both dimensions. + */ +internal val AvatarStackBorderSize = 2.dp + /** * A composable that displays a stack of user avatars. The avatars can overlap. * @@ -52,7 +58,6 @@ internal fun UserAvatarStack( ) { val componentFactory = ChatTheme.componentFactory val colors = ChatTheme.colors - val borderSize = 2.dp Row( modifier, @@ -63,9 +68,9 @@ internal fun UserAvatarStack( componentFactory.UserAvatar( params = UserAvatarParams( modifier = Modifier - .size(avatarSize + borderSize) - .border(borderSize, colors.borderCoreOnInverse, CircleShape) - .padding(borderSize), + .size(avatarSize + AvatarStackBorderSize) + .border(AvatarStackBorderSize, colors.borderCoreOnInverse, CircleShape) + .padding(AvatarStackBorderSize), user = user, ), ) 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 a80ef72b1c7..888d2886012 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 @@ -21,7 +21,6 @@ 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.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -225,51 +224,52 @@ private fun PollMessageContent( content = poll.name, isReply = message.replyTo != null, ) - Column( - modifier = Modifier - .passiveRipple() - .padding(StreamTokens.spacingMd), - ) { - Text( - modifier = Modifier.semantics { contentDescription = senderAwareName }, - text = poll.name, - style = typography.bodyEmphasis, - color = style.textColor, - ) - - Text( - modifier = Modifier.padding(top = StreamTokens.spacing2xs), - text = poll.getSubtitle(context), - style = typography.captionDefault, - color = style.textColor, - ) - - poll.options.take(PollsConstants.MAX_NUMBER_OF_VISIBLE_OPTIONS).forEachIndexed { index, option -> - val padding = PaddingValues( - top = if (index > 0) StreamTokens.spacingLg else StreamTokens.spacingMd, - bottom = if (index == poll.options.size - 1) StreamTokens.spacingLg else 0.dp, + Column(modifier = Modifier.passiveRipple()) { + Column( + modifier = Modifier.padding(StreamTokens.spacingMd), + verticalArrangement = Arrangement.spacedBy(StreamTokens.spacing2xs), + ) { + Text( + modifier = Modifier.semantics { contentDescription = senderAwareName }, + text = poll.name, + style = typography.bodyEmphasis, + color = style.textColor, ) - val voteCount = poll.voteCountsByOption[option.id] ?: 0 - PollOptionVotingRow( - modifier = Modifier.padding(padding), - poll = poll, - option = option, - voteCount = voteCount, - users = remember(poll.votes, option) { poll.getVotes(option).mapNotNull(Vote::user) }, - totalVoteCount = poll.voteCountsByOption.values.sum(), - checked = poll.ownVotes.any { it.optionId == option.id }, - style = style, - onCastVote = { onCastVote.invoke(option) }, - onRemoveVote = { - poll.ownVotes.firstOrNull { it.optionId == option.id } - ?.let(onRemoveVote) - }, - onLongClick = { onLongItemClick(message) }, + Text( + text = poll.getSubtitle(context), + style = typography.captionDefault, + color = style.textColor, ) } + Column( + modifier = Modifier.padding(horizontal = StreamTokens.spacingXs), + verticalArrangement = Arrangement.spacedBy(StreamTokens.spacing3xs), + ) { + poll.options.take(PollsConstants.MAX_NUMBER_OF_VISIBLE_OPTIONS).forEach { option -> + val voteCount = poll.voteCountsByOption[option.id] ?: 0 + + PollOptionVotingRow( + poll = poll, + option = option, + voteCount = voteCount, + users = remember(poll.votes, option) { poll.getVotes(option).mapNotNull(Vote::user) }, + totalVoteCount = poll.voteCountsByOption.values.sum(), + checked = poll.ownVotes.any { it.optionId == option.id }, + style = style, + onCastVote = { onCastVote.invoke(option) }, + onRemoveVote = { + poll.ownVotes.firstOrNull { it.optionId == option.id } + ?.let(onRemoveVote) + }, + onLongClick = { onLongItemClick(message) }, + ) + } + } + PollButtons( + modifier = Modifier.padding(StreamTokens.spacingMd), poll = poll, style = style, selectPoll = selectPoll, @@ -293,11 +293,15 @@ private fun PollButtons( showAddAnswerDialog: MutableState, isMine: Boolean, onClosePoll: (String) -> Unit, + modifier: Modifier = Modifier, ) { val outlinedButtonStyle = StreamButtonStyleDefaults.secondaryOutline.copy(borderColor = style.outlineColor) val ghostButtonStyle = StreamButtonStyleDefaults.secondaryGhost - Column(verticalArrangement = Arrangement.spacedBy(StreamTokens.spacingXs)) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(StreamTokens.spacingXs), + ) { if (poll.options.size > PollsConstants.MAX_NUMBER_OF_VISIBLE_OPTIONS) { PollOptionButton( text = stringResource(id = UiCommonR.string.stream_ui_poll_action_see_all, poll.options.size), 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 df3df36aa8b..ff4c9ba4913 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 @@ -188,7 +188,7 @@ private fun PollMoreOptionsItemList( horizontal = StreamTokens.spacingXs, vertical = StreamTokens.spacingMd, ), - verticalArrangement = Arrangement.spacedBy(StreamTokens.spacingXs), + verticalArrangement = Arrangement.spacedBy(StreamTokens.spacing3xs), ) { poll.options.forEach { option -> val voteCount = poll.voteCountsByOption[option.id] ?: 0 @@ -223,7 +223,6 @@ private fun PollMoreOptionItem( ) { val colors = ChatTheme.colors PollOptionVotingRow( - modifier = Modifier.padding(StreamTokens.spacingXs), poll = poll, option = option, voteCount = voteCount, 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 index 8ea6fd4d1a3..c632b6e8806 100644 --- 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 @@ -19,6 +19,7 @@ package io.getstream.chat.android.compose.ui.components.poll import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -29,8 +30,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.Text +import androidx.compose.material3.ripple 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 @@ -43,10 +46,11 @@ import androidx.compose.ui.semantics.hideFromAccessibility import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.toggleableState import androidx.compose.ui.state.ToggleableState -import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.style.LineHeightStyle 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.AvatarStackBorderSize 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 @@ -109,13 +113,17 @@ internal fun PollOptionVotingRow( onRemoveVote() } } + val interactionSource = remember(::MutableInteractionSource) Row( modifier = modifier .fillMaxWidth() + .clip(RoundedCornerShape(StreamTokens.radiusLg)) .applyIf(!poll.closed) { // The toggle's own gesture handling would otherwise consume the long-press as a // tap, so forward it to the message's actions-menu handler. combinedClickable( + interactionSource = interactionSource, + indication = ripple(), role = toggleRole, onClick = { onToggle(!checked) }, onLongClick = onLongClick, @@ -126,52 +134,75 @@ internal fun PollOptionVotingRow( .semantics { toggleableState = if (checked) ToggleableState.On else ToggleableState.Off } - }, + } + // Inset the option content so the clickable/ripple frame wraps the whole option + // (radio, text, votes and progress bar) with a consistent padding. + .padding(StreamTokens.spacingXs), horizontalArrangement = Arrangement.spacedBy(StreamTokens.spacingSm), - verticalAlignment = Alignment.CenterVertically, + verticalAlignment = Alignment.Top, ) { if (!poll.closed) { RadioCheck( - modifier = Modifier.semantics { hideFromAccessibility() }, + modifier = Modifier + .padding(vertical = StreamTokens.spacing2xs) + .semantics { hideFromAccessibility() }, checked = checked, onCheckedChange = onToggle, borderColor = style.outlineColor, ) } + val avatarSize = AvatarSize.ExtraSmall + // A voter avatar renders at avatarSize + AvatarStackBorderSize. Reserve that height for the + // votes area on non-anonymous polls so options with a voter avatar and options without one + // keep the same height. + val showsAvatars = poll.votingVisibility != VotingVisibility.ANONYMOUS + val votesMinHeight = if (showsAvatars) avatarSize + AvatarStackBorderSize else avatarSize + Column(verticalArrangement = Arrangement.spacedBy(StreamTokens.spacing2xs)) { - Row(Modifier.heightIn(min = AvatarSize.ExtraSmall)) { + Row( + horizontalArrangement = Arrangement.spacedBy(StreamTokens.spacingXs), + verticalAlignment = Alignment.Top, + ) { Text( modifier = Modifier.weight(1f), text = option.text, - style = ChatTheme.typography.captionDefault, + // Keep the full line box on every line (including a single line) so the gap + // to the progress bar stays consistent regardless of how many lines the + // option wraps to. + style = ChatTheme.typography.captionDefault.copy( + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Proportional, + trim = LineHeightStyle.Trim.None, + ), + ), 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, - ) + Row( + modifier = Modifier.heightIn(min = votesMinHeight), + verticalAlignment = Alignment.CenterVertically, + ) { + if (users.isNotEmpty() && showsAvatars) { + UserAvatarStack( + overlap = StreamTokens.spacingXs, + users = users.take(MaxStackedAvatars), + avatarSize = avatarSize, + modifier = Modifier.padding(end = StreamTokens.spacing2xs), + ) + } + Text( + modifier = Modifier.semantics { contentDescription = voteCountDescription }, + text = voteCount.toString(), + style = ChatTheme.typography.metadataDefault, + color = style.textColor, + ) + } } val progress by animateFloatAsState( diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/PollMessageContentTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/PollMessageContentTest.kt index 8768e8ea927..e4e997d07ed 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/PollMessageContentTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/PollMessageContentTest.kt @@ -25,6 +25,8 @@ import io.getstream.chat.android.compose.ui.PIXEL_2_HDPI import io.getstream.chat.android.compose.ui.PaparazziComposeTest import io.getstream.chat.android.compose.util.extensions.toSet import io.getstream.chat.android.models.ChannelCapabilities +import io.getstream.chat.android.previewdata.PreviewMessageData +import io.getstream.chat.android.previewdata.PreviewPollData import io.getstream.chat.android.ui.common.state.messages.list.MessageItemState import org.junit.Rule import org.junit.Test @@ -50,7 +52,75 @@ internal class PollMessageContentTest : PaparazziComposeTest { onClosePoll = {}, onAddPollOption = { _, _ -> }, messageItem = MessageItemState( - message = io.getstream.chat.android.previewdata.PreviewMessageData.messageWithPoll, + message = PreviewMessageData.messageWithPoll, + isMine = true, + ownCapabilities = ChannelCapabilities.toSet(), + ), + ) + } + } + } + + @Test + fun `poll content with long option text`() { + // The first long option has voter avatars; the second long option has none, so the + // no-avatar case (vote count kept off the wrapped text) is covered too. + val longText = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do " + + "eiusmod tempor incididunt ut labore et dolore magna aliqua" + val poll = PreviewPollData.poll1.let { poll -> + val noVotesOptionId = poll.options[1].id + poll.copy( + options = poll.options.mapIndexed { index, option -> + if (index < 2) option.copy(text = longText) else option + }, + votes = poll.votes.filterNot { it.optionId == noVotesOptionId }, + voteCountsByOption = poll.voteCountsByOption - noVotesOptionId, + ) + } + snapshotWithDarkMode { + Box(modifier = Modifier.fillMaxWidth()) { + PollMessageContent( + modifier = Modifier, + onCastVote = { _, _, _ -> }, + onRemoveVote = { _, _, _ -> }, + selectPoll = { _, _, _ -> }, + onAddAnswer = { _, _, _ -> }, + onClosePoll = {}, + onAddPollOption = { _, _ -> }, + messageItem = MessageItemState( + message = PreviewMessageData.messageWithPoll.copy(poll = poll), + isMine = true, + ownCapabilities = ChannelCapabilities.toSet(), + ), + ) + } + } + } + + @Test + fun `poll content with mixed vote states`() { + // Only the first option has a voter avatar; the others have none. All options must keep + // the same height so a voter avatar does not make its option taller. + val poll = PreviewPollData.poll1.let { poll -> + val votedOptionId = poll.options.first().id + poll.copy( + options = poll.options.map { it.copy(text = "Amsterdam") }, + votes = poll.votes.filter { it.optionId == votedOptionId }, + voteCountsByOption = mapOf(votedOptionId to 3), + ) + } + snapshotWithDarkMode { + Box(modifier = Modifier.fillMaxWidth()) { + PollMessageContent( + modifier = Modifier, + onCastVote = { _, _, _ -> }, + onRemoveVote = { _, _, _ -> }, + selectPoll = { _, _, _ -> }, + onAddAnswer = { _, _, _ -> }, + onClosePoll = {}, + onAddPollOption = { _, _ -> }, + messageItem = MessageItemState( + message = PreviewMessageData.messageWithPoll.copy(poll = poll), isMine = true, ownCapabilities = ChannelCapabilities.toSet(), ), @@ -72,7 +142,7 @@ internal class PollMessageContentTest : PaparazziComposeTest { onClosePoll = {}, onAddPollOption = { _, _ -> }, messageItem = MessageItemState( - message = io.getstream.chat.android.previewdata.PreviewMessageData.messageWithError, + message = PreviewMessageData.messageWithError, isMine = true, ownCapabilities = ChannelCapabilities.toSet(), ), diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.messages_PollMessageContentTest_poll_content.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.messages_PollMessageContentTest_poll_content.png index b7796dc60d8..5a49d4eef94 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.messages_PollMessageContentTest_poll_content.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.messages_PollMessageContentTest_poll_content.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.messages_PollMessageContentTest_poll_content_with_long_option_text.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.messages_PollMessageContentTest_poll_content_with_long_option_text.png new file mode 100644 index 00000000000..db1ac0bbce8 Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.messages_PollMessageContentTest_poll_content_with_long_option_text.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.messages_PollMessageContentTest_poll_content_with_mixed_vote_states.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.messages_PollMessageContentTest_poll_content_with_mixed_vote_states.png new file mode 100644 index 00000000000..0e37dd6ea40 Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.messages_PollMessageContentTest_poll_content_with_mixed_vote_states.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.poll_PollMoreOptionsDialogTest_more_options.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.poll_PollMoreOptionsDialogTest_more_options.png index 539c7492abe..8ef669c149c 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.poll_PollMoreOptionsDialogTest_more_options.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.poll_PollMoreOptionsDialogTest_more_options.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.poll_PollMoreOptionsDialogTest_more_options_in_dark_mode.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.poll_PollMoreOptionsDialogTest_more_options_in_dark_mode.png index 270b431f873..b671050d766 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.poll_PollMoreOptionsDialogTest_more_options_in_dark_mode.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.components.poll_PollMoreOptionsDialogTest_more_options_in_dark_mode.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_MessageListTest_loaded_messages.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_MessageListTest_loaded_messages.png index e773a985d14..3baed260b5c 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_MessageListTest_loaded_messages.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_MessageListTest_loaded_messages.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_MessageListTest_loaded_messages_in_dark_mode.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_MessageListTest_loaded_messages_in_dark_mode.png index 3841bd892a0..18f620d206d 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_MessageListTest_loaded_messages_in_dark_mode.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_MessageListTest_loaded_messages_in_dark_mode.png differ