Browse Source

Place transparent outline reaction heart below Android message bubbles

master
evgeny 5 days ago
parent
commit
aa58c7fd3d
  1. 12
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationMessages.kt
  2. 20
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt
  3. 55
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/PollUi.kt

12
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationMessages.kt

@ -87,10 +87,14 @@ fun ConversationMessages(target: ConversationTarget, messages: List<Message>, st
modifier = Modifier.padding(start = 8.dp, bottom = 4.dp), size = 32.dp)
Box(Modifier.weight(1f)) {
Column {
if (target is ConversationTarget.Channel && message.contentType == "poll") PollMessageBubble(message, onVote, authorName)
else MessageBubble(message, playbackState, onPlayVoice, onSeekVoice, onDownloadFile, onOpenFile, onOpenImage, onPlayVideo, onLink)
if (target is ConversationTarget.Channel && message.messageId.isNotEmpty())
MessageReactions(message, reactionCatalog, onReact)
val footer: @Composable () -> Unit = {
if (target is ConversationTarget.Channel && message.messageId.isNotEmpty())
MessageReactions(message, reactionCatalog, onReact)
}
if (target is ConversationTarget.Channel && message.contentType == "poll")
PollMessageBubble(message, onVote, authorName, footer)
else MessageBubble(message, playbackState, onPlayVoice, onSeekVoice, onDownloadFile,
onOpenFile, onOpenImage, onPlayVideo, onLink, footer)
}
}
if (message.isOutgoing) UserAvatar(group, message.authorNodeId, message.author,

20
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt

@ -42,6 +42,7 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
@ -100,6 +101,20 @@ private fun detectEmojiOnly(text: String): Int {
private fun deliveryLabel(message: Message): String =
if (message.deliveryStatusRes == 0) "" else stringResource(message.deliveryStatusRes)
/* Footer получает ширину пузыря после измерения содержимого, включая медиа. */
@Composable
internal fun MessageWithFooter(footer: @Composable () -> Unit, content: @Composable () -> Unit) {
Layout(content = { Box { content() }; Box { footer() } }) { children, constraints ->
val childConstraints = constraints.copy(minWidth = 0, minHeight = 0)
val body = children[0].measure(childConstraints)
val bottom = children[1].measure(childConstraints.copy(minWidth = body.width, maxWidth = body.width))
layout(body.width, body.height + bottom.height) {
body.placeRelative(0, 0)
bottom.placeRelative(0, body.height)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = ChatViewModel.PlaybackState(),
@ -109,7 +124,8 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
onOpenFile: (Message) -> Unit = {},
onOpenImage: (Message) -> Unit = {},
onPlayVideo: (Message) -> Unit = {},
onLink: (String) -> Unit = {}) {
onLink: (String) -> Unit = {},
footer: @Composable () -> Unit = {}) {
val bgColor = if (message.isOutgoing) ChatColors.OutgoingBubble else ChatColors.IncomingBubble
val textColor = ChatColors.Text
val alignment = if (message.isOutgoing) Arrangement.End else Arrangement.Start
@ -141,6 +157,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp),
horizontalArrangement = alignment
) {
MessageWithFooter(footer) {
Column(
modifier = Modifier
.widthIn(min = minBubbleWidth, max = if (isVoice) voiceMaxWidth else 300.dp)
@ -196,6 +213,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
)
}
}
}
}
}
@Composable

55
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/PollUi.kt

@ -1,5 +1,6 @@
package com.utun.chat.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
@ -8,7 +9,15 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.utun.chat.R
import com.utun.chat.data.*
@ -45,10 +54,14 @@ fun CreatePollDialog(onDismiss: () -> Unit, onCreate: (String, List<String>, Boo
}
@Composable
fun PollMessageBubble(message: Message, onVote: (Message, List<Int>, String) -> Unit, authorName: (Long) -> String) {
fun PollMessageBubble(message: Message, onVote: (Message, List<Int>, String) -> Unit, authorName: (Long) -> String,
footer: @Composable () -> Unit = {}) {
var showVote by rememberSaveable(message.messageId) { mutableStateOf(false) }
val poll = message.feedback?.poll
Card(onClick = { if (poll != null) showVote = true }, modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp),
horizontalArrangement = if (message.isOutgoing) Arrangement.End else Arrangement.Start) {
MessageWithFooter(footer) {
Card(onClick = { if (poll != null) showVote = true }, modifier = Modifier.widthIn(max = 300.dp),
colors = CardDefaults.cardColors(containerColor = if (message.isOutgoing) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceVariant)) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
@ -70,6 +83,8 @@ fun PollMessageBubble(message: Message, onVote: (Message, List<Int>, String) ->
}
}
}
}
}
if (showVote && poll != null) PollVoteDialog(message, poll, { showVote = false }, onVote, authorName)
}
@ -123,12 +138,20 @@ private fun PollVoteDialog(message: Message, poll: PollState, onDismiss: () -> U
@Composable
fun MessageReactions(message: Message, catalog: List<ReactionState>, onReact: (Message, Int?) -> Unit) {
var picker by rememberSaveable(message.messageId) { mutableStateOf(false) }
FlowRow(Modifier.padding(horizontal = 8.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
FlowRow(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
IconButton(onClick = {
LogManager.addLog("DEBUG", "POLL", "reaction picker opened message=${message.messageId} type=${message.contentType}")
picker = true
}) {
ReactionHeart(stringResource(R.string.poll_react))
}
for (reaction in message.feedback?.reactions.orEmpty()) {
FilterChip(selected = reaction.mine, onClick = { onReact(message, reaction.id) },
FilterChip(selected = reaction.mine, onClick = {
LogManager.addLog("DEBUG", "POLL", "reaction toggled message=${message.messageId} emoji=${reaction.id} mine=${reaction.mine}")
onReact(message, reaction.id)
},
label = { Text("${reaction.emoji} ${reaction.count}") })
}
TextButton(onClick = { picker = true }) { Text(stringResource(R.string.poll_react)) }
}
if (picker) AlertDialog(onDismissRequest = { picker = false }, title = { Text(stringResource(R.string.poll_react)) },
text = { FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
@ -139,3 +162,25 @@ fun MessageReactions(message: Message, catalog: List<ReactionState>, onReact: (M
} },
dismissButton = { TextButton(onClick = { picker = false }) { Text(stringResource(R.string.close)) } })
}
/* Два контура без заливки: более широкий тёмный даёт тень ровно в один физический пиксель. */
@Composable
private fun ReactionHeart(description: String) {
val path = remember {
Path().apply {
moveTo(12f, 21f)
cubicTo(10.5f, 19.5f, 3f, 14.2f, 3f, 8.5f)
cubicTo(3f, 3f, 9f, 2f, 12f, 6f)
cubicTo(15f, 2f, 21f, 3f, 21f, 8.5f)
cubicTo(21f, 14.2f, 13.5f, 19.5f, 12f, 21f)
close()
}
}
Canvas(Modifier.size(24.dp).semantics { contentDescription = description }) {
val factor = size.width / 24f
scale(factor, factor, pivot = Offset.Zero) {
drawPath(path, Color.Black.copy(alpha = 0.6f), style = Stroke(width = 1.8f + 2f / factor, join = StrokeJoin.Round))
drawPath(path, Color.White, style = Stroke(width = 1.8f, join = StrokeJoin.Round))
}
}
}

Loading…
Cancel
Save