diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadDivider.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadDivider.kt index 723daac54a..2f8a138dff 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadDivider.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadDivider.kt @@ -62,6 +62,3 @@ internal fun PagingData.withSeparators( insertSeparators { newer: ChatListItem.ContentBubble?, older: ChatListItem.ContentBubble? -> separatorBetween(newer, older, boundary, config) } - -/** The count the divider shows: past 99 it reads "99+". */ -internal fun unreadCountLabel(count: Int): String = if (count > 99) "99+" else count.toString() diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/UnreadDividerRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/UnreadDividerRow.kt index f35c34c17d..23a8b2d5bc 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/UnreadDividerRow.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/UnreadDividerRow.kt @@ -13,10 +13,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper -import com.flipcash.app.messenger.internal.unreadCountLabel import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.features.messenger.R import com.getcode.theme.CodeTheme +import com.getcode.ui.components.unreadCountLabel import kotlin.time.Clock import kotlin.time.Instant diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadDividerPlacementTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadDividerPlacementTest.kt index ca0f1ea4ed..81c72b8213 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadDividerPlacementTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadDividerPlacementTest.kt @@ -98,11 +98,4 @@ class UnreadDividerPlacementTest { separatorBetween(newer, older, UnreadBoundary.None, SeparatorConfig.DayOnly), ) } - - @Test - fun `counts over 99 read 99+`() { - assertEquals("1", unreadCountLabel(1)) - assertEquals("99", unreadCountLabel(99)) - assertEquals("99+", unreadCountLabel(100)) - } } diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/Badge.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/Badge.kt index 97d92049e4..4ec5574dad 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/Badge.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/Badge.kt @@ -43,11 +43,13 @@ private fun Modifier.squareMinSize(): Modifier = layout { measurable, constraint } } +/** An unread count as a badge or divider shows it: past 99 it reads "99+". */ +fun unreadCountLabel(count: Int): String = if (count > 99) "99+" else count.toString() + @Composable fun Badge( count: Int, modifier: Modifier = Modifier, - showMoreUnread: Boolean = count > 100, color: Color = CodeTheme.colors.brand, contentColor: Color = Color.White, textStyle: TextStyle = CodeTheme.typography.caption.copy(fontWeight = FontWeight.SemiBold), @@ -66,11 +68,7 @@ fun Badge( enter = enterTransition, exit = exitTransition ) { - val text = when { - count == 0 -> "" - showMoreUnread -> "$count+" - else -> "$count" - } + val text = if (count == 0) "" else unreadCountLabel(count) val scaledTextStyle = if (scale == 1f) textStyle else textStyle.copy( fontSize = textStyle.fontSize * scale diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatNode.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatNode.kt deleted file mode 100644 index cfd2b27a75..0000000000 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatNode.kt +++ /dev/null @@ -1,197 +0,0 @@ -package com.getcode.ui.components.chat - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.BoxScope -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.layout.size -import androidx.compose.foundation.text.InlineTextContent -import androidx.compose.material.Icon -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.VolumeOff -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.style.TextOverflow -import com.getcode.theme.CodeTheme -import com.getcode.ui.components.Badge -import androidx.compose.foundation.clickable -import com.getcode.util.DateUtils -import com.getcode.util.formatTimeRelatively - -@Composable -fun ChatNode( - title: String, - modifier: Modifier = Modifier, - avatar: Any? = null, - avatarIconWhenFallback: @Composable BoxScope.() -> Unit = { }, - messagePreview: Pair>, - titleTextStyle: TextStyle = CodeTheme.typography.textMedium, - messageTextStyle: TextStyle = CodeTheme.typography.textMedium, - messageMinLines: Int = 2, - timestamp: Long? = null, - isMuted: Boolean = false, - isHost: Boolean = false, - unreadCount: Int = 0, - showMoreUnread: Boolean = unreadCount > 99, - onClick: () -> Unit, -) { - Row( - modifier = modifier - .clickable { onClick() } - .padding( - vertical = CodeTheme.dimens.grid.x3, - horizontal = CodeTheme.dimens.inset - ), - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), - verticalAlignment = Alignment.CenterVertically - ) { - avatar?.let { - HostableAvatar( - size = CodeTheme.dimens.staticGrid.x12, - imageData = it, - overlay = avatarIconWhenFallback, - isHost = isHost - ) - } - - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy( - CodeTheme.dimens.grid.x1, - Alignment.CenterVertically - ), - ) { - val hasUnreadMessages = remember(unreadCount) { unreadCount > 0 } - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2) - ) { - Text( - modifier = Modifier.weight(1f), - text = title, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = titleTextStyle - ) - timestamp?.let { - val isToday = DateUtils.isToday(it) - Text( - text = if (isToday) { - it.formatTimeRelatively() - } else { - DateUtils.getDateRelatively(it) - }, - style = CodeTheme.typography.textSmall, - color = when { - hasUnreadMessages -> CodeTheme.colors.indicator - else -> CodeTheme.colors.textSecondary - }, - ) - } - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.inset), - verticalAlignment = Alignment.Top - ) { - - val (preview, inlineContent) = messagePreview - - Text( - modifier = Modifier.weight(1f), - text = preview, - inlineContent = inlineContent, - style = messageTextStyle, - color = CodeTheme.colors.textSecondary, - minLines = messageMinLines.coerceAtMost(2), - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - Row( - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - verticalAlignment = Alignment.CenterVertically - ) { - if (isMuted) { - Icon( - modifier = Modifier - .size(CodeTheme.dimens.staticGrid.x4), - imageVector = Icons.AutoMirrored.Filled.VolumeOff, - contentDescription = "chat is muted", - tint = CodeTheme.colors.textSecondary - ) - } - - Badge( - count = unreadCount, - showMoreUnread = showMoreUnread, - color = CodeTheme.colors.indicator, - contentColor = Color.White - ) - } - - } - } - } -} - -//private val Chat.messagePreview: Pair> -// @Composable get() { -// val contents = newestMessage?.contents ?: return AnnotatedString("No content") to emptyMap() -// -// var filtered: List = contents.filterIsInstance() -// if (filtered.isEmpty()) { -// filtered = contents -// } -// -// val selfMember = self -// val pointer = -// selfMember?.pointers.orEmpty().find { it.messageId == newestMessage?.id?.uuid } -// -// // joinToString does expose a Composable scoped lambda -// @Suppress("SimplifiableCallChain") -// val messageBody = filtered.map { it.localizedText }.joinToString(" ") -// -// val textStyle = CodeTheme.typography.textMedium -// return if (pointer != null && pointer !is Pointer.Unknown && isConversation) { -// val string = buildAnnotatedString { -// appendInlineContent("status", "status") -// append(" ") -// append(messageBody) -// } -// -// string to mapOf( -// "status" to InlineTextContent( -// Placeholder( -// textStyle.fontSize, -// textStyle.fontSize, -// PlaceholderVerticalAlign.TextCenter -// ) -// ) { -// Image( -// painter = painterResource( -// id = when (pointer) { -// is Pointer.Delivered -> R.drawable.ic_message_status_delivered -// is Pointer.Read -> R.drawable.ic_message_status_read -// is Pointer.Sent -> R.drawable.ic_message_status_sent -// else -> -1 -// } -// ), -// modifier = Modifier.fillMaxSize(), -// contentDescription = "" -// ) -// } -// ) -// } else { -// AnnotatedString(messageBody) to emptyMap() -// } -// } \ No newline at end of file diff --git a/ui/components/src/test/kotlin/com/getcode/ui/components/BadgeTest.kt b/ui/components/src/test/kotlin/com/getcode/ui/components/BadgeTest.kt new file mode 100644 index 0000000000..0ff85a061a --- /dev/null +++ b/ui/components/src/test/kotlin/com/getcode/ui/components/BadgeTest.kt @@ -0,0 +1,74 @@ +package com.getcode.ui.components + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertHeightIsEqualTo +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.unit.dp +import com.getcode.theme.DesignSystem +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +class BadgeTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun `counts up to 99 read as the number`() { + assertEquals("1", unreadCountLabel(1)) + assertEquals("99", unreadCountLabel(99)) + } + + @Test + fun `counts over 99 read 99+`() { + assertEquals("99+", unreadCountLabel(100)) + assertEquals("99+", unreadCountLabel(150)) + } + + @Test + fun `badge shows 99 at 99`() = assertBadgeText(count = 99, expected = "99") + + @Test + fun `badge caps at 99+ at 100`() = assertBadgeText(count = 100, expected = "99+") + + @Test + fun `badge caps at 99+ at 150`() = assertBadgeText(count = 150, expected = "99+") + + // The chat row pill (`UnreadBadge`): an 18dp fixed height with 4dp side padding. At the cap it + // must stay 18dp tall and wide enough to hold the whole label. + @Test + fun `99+ keeps the 18dp pill height and fits inside it`() { + composeTestRule.setContent { + DesignSystem { + Badge( + modifier = Modifier.testTag("badge"), + count = 150, + contentPadding = PaddingValues(horizontal = 4.dp), + height = 18.dp, + ) + } + } + composeTestRule.waitForIdle() + + val pill = composeTestRule.onNodeWithTag("badge").assertHeightIsEqualTo(18.dp).getBoundsInRoot() + val label = composeTestRule.onNodeWithText("99+").getBoundsInRoot() + assertTrue(label.left >= pill.left && label.right <= pill.right, "label $label overflows pill $pill") + assertTrue(label.top >= pill.top && label.bottom <= pill.bottom, "label $label overflows pill $pill") + } + + private fun assertBadgeText(count: Int, expected: String) { + composeTestRule.setContent { DesignSystem { Badge(count = count) } } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(expected).assertExists() + } +}