From be3f882f7465cca01ae04d99bfafb13ba1947dc6 Mon Sep 17 00:00:00 2001 From: Phil Oliver <3497406+poliver@users.noreply.github.com> Date: Sun, 2 Nov 2025 15:10:05 -0500 Subject: [PATCH] wip --- .../mesh/ui/message/components/MessageItem.kt | 138 +++++++++++------- .../mesh/ui/message/components/Reaction.kt | 121 ++++++++------- 2 files changed, 153 insertions(+), 106 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt index ed5d49624..dcef53408 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt @@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentSize @@ -35,6 +36,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.FormatQuote +import androidx.compose.material.icons.rounded.FormatQuote import androidx.compose.material3.Card import androidx.compose.material3.CardColors import androidx.compose.material3.CardDefaults @@ -56,11 +58,14 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp +import androidx.room.util.newStringBuilder +import com.geeksville.mesh.MeshProtos import com.geeksville.mesh.MessageStatus import com.geeksville.mesh.R import com.geeksville.mesh.database.entity.Reaction import com.geeksville.mesh.model.Message import com.geeksville.mesh.model.Node +import com.geeksville.mesh.model.getStringResFrom import com.geeksville.mesh.ui.common.components.EmojiPickerDialog import com.geeksville.mesh.ui.common.components.MDText import com.geeksville.mesh.ui.common.preview.NodePreviewParameterProvider @@ -159,7 +164,7 @@ internal fun MessageItem( onLongClick = { showMessageActionsDialog = true }, onClick = {}, ) - .padding(horizontal = 8.dp, vertical = 4.dp), + .padding(start = 8.dp, end = 8.dp, bottom = 16.dp), ) { @Suppress("MagicNumber") Column( @@ -168,68 +173,85 @@ internal fun MessageItem( .align(if (message.fromLocal) Alignment.CenterEnd else Alignment.CenterStart), horizontalAlignment = if (message.fromLocal) Alignment.End else Alignment.Start, ) { - Card( - modifier = - Modifier.wrapContentSize() - .then( - if (containsBel) { - Modifier.border(2.dp, MessageItemColors.Red, shape = MaterialTheme.shapes.medium) - } else { - Modifier - }, - ), - shape = RoundedCornerShape(20.dp), - colors = cardColors, - ) { - Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { - OriginalMessageSnippet( - message = message, - ourNode = ourNode, - cardColors = cardColors, - onNavigateToOriginalMessage = onNavigateToOriginalMessage, - ) - - Column { - MDText( - text = message.text, - style = MaterialTheme.typography.bodyMedium, - color = cardColors.contentColor, + Box { + Card( + modifier = + Modifier.wrapContentSize() + .then( + if (containsBel) { + Modifier.border( + 2.dp, + MessageItemColors.Red, + shape = MaterialTheme.shapes.medium, + ) + } else { + Modifier + }, + ) + .align(if (message.fromLocal) Alignment.CenterEnd else Alignment.CenterStart), + shape = RoundedCornerShape(20.dp), + colors = cardColors, + ) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + OriginalMessageSnippet( + message = message, + ourNode = ourNode, + cardColors = cardColors, + onNavigateToOriginalMessage = onNavigateToOriginalMessage, ) - } - Row( - modifier = Modifier.padding(horizontal = 4.dp).align(Alignment.End), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - if (message.viaMqtt) { - Icon( - Icons.Default.Cloud, - contentDescription = stringResource(R.string.via_mqtt), - modifier = Modifier.size(12.dp), + MDText( + text = message.text, + style = MaterialTheme.typography.bodyMedium, + color = cardColors.contentColor, ) + + Row( + modifier = Modifier.padding(horizontal = 4.dp).align(Alignment.End), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (message.viaMqtt) { + Icon( + Icons.Default.Cloud, + contentDescription = stringResource(R.string.via_mqtt), + modifier = Modifier.size(12.dp), + ) + } } } } + + ReactionRow( + reactions = emojis, + onShowReactions = onShowReactions, + modifier = + Modifier.align(if (message.fromLocal) Alignment.TopStart else Alignment.TopEnd) + .offset(y = (-14).dp), + ) } Spacer(modifier = Modifier.height(4.dp)) Row(verticalAlignment = Alignment.CenterVertically) { - if (containsBel) { - Text(text = "\uD83D\uDD14", modifier = Modifier.padding(end = 4.dp)) + val bottomLabel = buildString { + if (containsBel) { + append("\uD83D\uDD14") + append(" · ") + } + + if(message.fromLocal) { + append(stringResource(message.getStatusStringRes().second)) + append(" · ") + } + + append(message.time) } - Text(text = message.time, style = MaterialTheme.typography.labelSmall) + + Text(text = bottomLabel, style = MaterialTheme.typography.labelSmall) } } } - - ReactionRow( - modifier = Modifier.fillMaxWidth(), - reactions = emojis, - onSendReaction = sendReaction, - onShowReactions = onShowReactions, - ) } @Composable @@ -255,7 +277,7 @@ private fun OriginalMessageSnippet( horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Icon( - Icons.Default.FormatQuote, + Icons.Rounded.FormatQuote, contentDescription = stringResource(R.string.reply), // Add to strings.xml ) Text( @@ -314,9 +336,23 @@ private fun MessageItemPreview() { read = false, routingError = 0, packetId = 4545, - emojis = listOf(), + emojis = + listOf( + Reaction( + replyId = 1, + user = MeshProtos.User.getDefaultInstance(), + emoji = "\uD83D\uDE42", + timestamp = 1L, + ), + Reaction( + replyId = 1, + user = MeshProtos.User.getDefaultInstance(), + emoji = "\uD83E\uDEE0", + timestamp = 1L, + ), + ), replyId = null, - viaMqtt = true, + viaMqtt = false, ) val receivedWithOriginalMessage = Message( diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/components/Reaction.kt b/app/src/main/java/com/geeksville/mesh/ui/message/components/Reaction.kt index 61bf5004e..e08d176a7 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/message/components/Reaction.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/message/components/Reaction.kt @@ -17,26 +17,25 @@ package com.geeksville.mesh.ui.message.components -import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Badge -import androidx.compose.material3.BadgedBox import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -47,64 +46,75 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.geeksville.mesh.MeshProtos import com.geeksville.mesh.database.entity.Reaction import com.geeksville.mesh.ui.common.components.BottomSheetDialog import com.geeksville.mesh.ui.common.theme.AppTheme -@Composable -private fun ReactionItem(emoji: String, emojiCount: Int = 1, onClick: () -> Unit = {}, onLongClick: () -> Unit = {}) { - BadgedBox( - badge = { - if (emojiCount > 1) { - Badge { Text(fontWeight = FontWeight.Bold, text = emojiCount.toString()) } - } - }, - ) { - Surface( - modifier = Modifier.combinedClickable(onClick = onClick, onLongClick = onLongClick), - color = MaterialTheme.colorScheme.primaryContainer, - shape = CircleShape, - ) { - Text(text = emoji, modifier = Modifier.padding(4.dp).clip(CircleShape)) - } - } -} - -@OptIn(ExperimentalLayoutApi::class) @Composable fun ReactionRow( modifier: Modifier = Modifier, reactions: List = emptyList(), - onSendReaction: (String) -> Unit = {}, onShowReactions: () -> Unit = {}, ) { - val emojiList = reduceEmojis(reactions.reversed().map { it.emoji }).entries + val emojiList = reactions.map { it.emoji }.distinct() - AnimatedVisibility(emojiList.isNotEmpty()) { - LazyRow( - modifier = modifier.padding(horizontal = 4.dp), - horizontalArrangement = Arrangement.Start, - verticalAlignment = Alignment.CenterVertically, + Box(modifier = modifier) { + EmojiStack( + offset = 12.dp, + modifier = + Modifier.combinedClickable( + interactionSource = null, + indication = null, + onLongClick = onShowReactions, + onClick = {}, + ), ) { - items(emojiList.size) { index -> - val entry = emojiList.elementAt(index) - ReactionItem( - emoji = entry.key, - emojiCount = entry.value, - onClick = { onSendReaction(entry.key) }, - onLongClick = onShowReactions, - ) + emojiList.forEach { emoji -> + Box( + modifier = + Modifier.size(24.dp) + .aspectRatio(1f) + .background(color = MaterialTheme.colorScheme.primaryContainer, shape = CircleShape), + contentAlignment = Alignment.Center, + ) { + Text( + text = emoji, + textAlign = TextAlign.Center, + fontSize = 14.sp, + modifier = Modifier.wrapContentSize(), + ) + } } } } } -fun reduceEmojis(emojis: List): Map = emojis.groupingBy { it }.eachCount() +@Composable +fun EmojiStack(modifier: Modifier = Modifier, offset: Dp, content: @Composable () -> Unit) { + Layout(content, modifier) { measurables, constraints -> + val placeables = measurables.map { measurable -> measurable.measure(constraints) } + + val height = if (placeables.isNotEmpty()) placeables.first().height else 0 + + val width = + if (placeables.isNotEmpty()) { + placeables.first().width + (offset.toPx().toInt() * (placeables.size - 1)) + } else { + 0 + } + + layout(width = width, height = height) { + placeables.mapIndexed { index, placeable -> placeable.place(x = offset.toPx().toInt() * index, y = 0) } + } + } +} @Composable fun ReactionDialog(reactions: List, onDismiss: () -> Unit = {}) = @@ -143,17 +153,6 @@ fun ReactionDialog(reactions: List, onDismiss: () -> Unit = {}) = } } -@PreviewLightDark -@Composable -fun ReactionItemPreview() { - AppTheme { - Column(modifier = Modifier.background(MaterialTheme.colorScheme.background)) { - ReactionItem(emoji = "\uD83D\uDE42") - ReactionItem(emoji = "\uD83D\uDE42", emojiCount = 2) - } - } -} - @Preview @Composable fun ReactionRowPreview() { @@ -173,6 +172,18 @@ fun ReactionRowPreview() { emoji = "\uD83D\uDE42", timestamp = 1L, ), + Reaction( + replyId = 1, + user = MeshProtos.User.getDefaultInstance(), + emoji = "\uD83E\uDEE0", + timestamp = 1L, + ), + Reaction( + replyId = 1, + user = MeshProtos.User.getDefaultInstance(), + emoji = "\uD83D\uDD12", + timestamp = 1L, + ), ), ) }