Browse Source

Place transparent outline reaction heart below Android message bubbles

master
evgeny 6 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) modifier = Modifier.padding(start = 8.dp, bottom = 4.dp), size = 32.dp)
Box(Modifier.weight(1f)) { Box(Modifier.weight(1f)) {
Column { Column {
if (target is ConversationTarget.Channel && message.contentType == "poll") PollMessageBubble(message, onVote, authorName) val footer: @Composable () -> Unit = {
else MessageBubble(message, playbackState, onPlayVoice, onSeekVoice, onDownloadFile, onOpenFile, onOpenImage, onPlayVideo, onLink) if (target is ConversationTarget.Channel && message.messageId.isNotEmpty())
if (target is ConversationTarget.Channel && message.messageId.isNotEmpty()) MessageReactions(message, reactionCatalog, onReact)
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, 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.awaitFirstDown
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -100,6 +101,20 @@ private fun detectEmojiOnly(text: String): Int {
private fun deliveryLabel(message: Message): String = private fun deliveryLabel(message: Message): String =
if (message.deliveryStatusRes == 0) "" else stringResource(message.deliveryStatusRes) 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) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = ChatViewModel.PlaybackState(), fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = ChatViewModel.PlaybackState(),
@ -109,7 +124,8 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
onOpenFile: (Message) -> Unit = {}, onOpenFile: (Message) -> Unit = {},
onOpenImage: (Message) -> Unit = {}, onOpenImage: (Message) -> Unit = {},
onPlayVideo: (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 bgColor = if (message.isOutgoing) ChatColors.OutgoingBubble else ChatColors.IncomingBubble
val textColor = ChatColors.Text val textColor = ChatColors.Text
val alignment = if (message.isOutgoing) Arrangement.End else Arrangement.Start 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), modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp),
horizontalArrangement = alignment horizontalArrangement = alignment
) { ) {
MessageWithFooter(footer) {
Column( Column(
modifier = Modifier modifier = Modifier
.widthIn(min = minBubbleWidth, max = if (isVoice) voiceMaxWidth else 300.dp) .widthIn(min = minBubbleWidth, max = if (isVoice) voiceMaxWidth else 300.dp)
@ -196,6 +213,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
) )
} }
} }
}
} }
} }
@Composable @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 package com.utun.chat.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@ -8,7 +9,15 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier 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.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.utun.chat.R import com.utun.chat.R
import com.utun.chat.data.* import com.utun.chat.data.*
@ -45,10 +54,14 @@ fun CreatePollDialog(onDismiss: () -> Unit, onCreate: (String, List<String>, Boo
} }
@Composable @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) } var showVote by rememberSaveable(message.messageId) { mutableStateOf(false) }
val poll = message.feedback?.poll 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 colors = CardDefaults.cardColors(containerColor = if (message.isOutgoing) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceVariant)) { else MaterialTheme.colorScheme.surfaceVariant)) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { 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) 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 @Composable
fun MessageReactions(message: Message, catalog: List<ReactionState>, onReact: (Message, Int?) -> Unit) { fun MessageReactions(message: Message, catalog: List<ReactionState>, onReact: (Message, Int?) -> Unit) {
var picker by rememberSaveable(message.messageId) { mutableStateOf(false) } 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()) { 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}") }) 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)) }, if (picker) AlertDialog(onDismissRequest = { picker = false }, title = { Text(stringResource(R.string.poll_react)) },
text = { FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { 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)) } }) 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