Refactor: Extract MessageActions components (#2225)

This commit is contained in:
James Rich 2025-06-22 17:26:05 +00:00 committed by GitHub
parent 7ae1ab921a
commit 0b19f842bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 254 additions and 152 deletions

View file

@ -0,0 +1,128 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.message.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Reply
import androidx.compose.material.icons.filled.EmojiEmotions
import androidx.compose.material.icons.twotone.Cloud
import androidx.compose.material.icons.twotone.CloudDone
import androidx.compose.material.icons.twotone.CloudOff
import androidx.compose.material.icons.twotone.CloudUpload
import androidx.compose.material.icons.twotone.HowToReg
import androidx.compose.material.icons.twotone.Warning
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.geeksville.mesh.MessageStatus
import com.geeksville.mesh.R
import com.geeksville.mesh.ui.common.components.EmojiPickerDialog
@Composable
fun ReactionButton(
onSendReaction: (String) -> Unit = {},
) {
var showEmojiPickerDialog by remember { mutableStateOf(false) }
if (showEmojiPickerDialog) {
EmojiPickerDialog(
onConfirm = { selectedEmoji ->
showEmojiPickerDialog = false
onSendReaction(selectedEmoji)
},
onDismiss = { showEmojiPickerDialog = false }
)
}
IconButton(
onClick = { showEmojiPickerDialog = true },
) {
Icon(
imageVector = Icons.Default.EmojiEmotions,
contentDescription = stringResource(R.string.react),
)
}
}
@Composable
fun ReplyButton(
onClick: () -> Unit = {},
) = IconButton(
onClick = onClick,
content = {
Icon(
imageVector = Icons.AutoMirrored.Filled.Reply,
contentDescription = stringResource(R.string.reply),
)
}
)
@Composable
fun MessageStatusButton(
onStatusClick: () -> Unit = {},
status: MessageStatus,
fromLocal: Boolean,
) = AnimatedVisibility(visible = fromLocal) {
IconButton(
onClick = onStatusClick
) {
Icon(
imageVector = when (status) {
MessageStatus.RECEIVED -> Icons.TwoTone.HowToReg
MessageStatus.QUEUED -> Icons.TwoTone.CloudUpload
MessageStatus.DELIVERED -> Icons.TwoTone.CloudDone
MessageStatus.ENROUTE -> Icons.TwoTone.Cloud
MessageStatus.ERROR -> Icons.TwoTone.CloudOff
else -> Icons.TwoTone.Warning
},
contentDescription = stringResource(R.string.message_delivery_status),
)
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun MessageActions(
modifier: Modifier = Modifier,
isLocal: Boolean = false,
status: MessageStatus?,
onSendReaction: (String) -> Unit = {},
onSendReply: () -> Unit = {},
onStatusClick: () -> Unit = {},
) {
Row(
modifier = modifier.wrapContentSize(),
) {
ReactionButton { onSendReaction(it) }
ReplyButton { onSendReply() }
MessageStatusButton(
onStatusClick = onStatusClick,
status = status ?: MessageStatus.UNKNOWN,
fromLocal = isLocal
)
}
}

View file

@ -17,7 +17,6 @@
package com.geeksville.mesh.ui.message.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@ -33,12 +32,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FormatQuote
import androidx.compose.material.icons.twotone.Cloud
import androidx.compose.material.icons.twotone.CloudDone
import androidx.compose.material.icons.twotone.CloudOff
import androidx.compose.material.icons.twotone.CloudUpload
import androidx.compose.material.icons.twotone.HowToReg
import androidx.compose.material.icons.twotone.Warning
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@ -125,51 +118,7 @@ internal fun MessageItem(
modifier = Modifier
.fillMaxWidth(),
) {
message.originalMessage?.let { originalMessage ->
val originalMessageIsFromLocal =
originalMessage.node.user.id == DataPacket.ID_LOCAL
val originalMessageNode =
if (originalMessageIsFromLocal) ourNode else originalMessage.node
OutlinedCard(
modifier = Modifier
.fillMaxWidth()
.clickable { onNavigateToOriginalMessage(originalMessage.packetId) },
colors = CardDefaults.cardColors(
containerColor = Color(originalMessageNode.colors.second).copy(alpha = 0.5f),
contentColor = Color(originalMessageNode.colors.first),
),
) {
Row(
modifier = Modifier.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.FormatQuote,
contentDescription = stringResource(R.string.reply), // Add to strings.xml
modifier = Modifier.size(14.dp), // Smaller icon
)
Spacer(Modifier.width(4.dp))
Column {
Text(
text = "${originalMessageNode.user.shortName} ${
originalMessageNode.user.longName
?: stringResource(R.string.unknown_username)
}",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = originalMessage.text, // Should not be null if isAReply is true
style = MaterialTheme.typography.bodySmall,
maxLines = 1, // Keep snippet brief
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
ReplyingTo(message, ourNode, onNavigateToOriginalMessage)
Row(
modifier = Modifier
@ -189,24 +138,27 @@ internal fun MessageItem(
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.weight(1f, fill = false)
modifier = Modifier.weight(1f, fill = true)
)
MessageActions(
isLocal = fromLocal,
status = message.status,
onSendReaction = sendReaction,
onSendReply = onReply,
onStatusClick = onStatusClick,
)
}
AutoLinkText(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
.padding(horizontal = 8.dp),
text = message.text,
style = MaterialTheme.typography.bodyMedium,
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp),
.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
@ -234,26 +186,11 @@ internal fun MessageItem(
)
}
}
Spacer(modifier = Modifier.weight(1f))
Text(
text = message.time,
style = MaterialTheme.typography.labelSmall,
)
AnimatedVisibility(visible = fromLocal) {
Icon(
imageVector = when (message.status) {
MessageStatus.RECEIVED -> Icons.TwoTone.HowToReg
MessageStatus.QUEUED -> Icons.TwoTone.CloudUpload
MessageStatus.DELIVERED -> Icons.TwoTone.CloudDone
MessageStatus.ENROUTE -> Icons.TwoTone.Cloud
MessageStatus.ERROR -> Icons.TwoTone.CloudOff
else -> Icons.TwoTone.Warning
},
contentDescription = stringResource(R.string.message_delivery_status),
modifier = Modifier
.padding(start = 8.dp)
.clickable { onStatusClick() },
)
}
}
}
}
@ -267,6 +204,59 @@ internal fun MessageItem(
)
}
@Composable
private fun ReplyingTo(
message: Message,
ourNode: Node,
onNavigateToOriginalMessage: (Int) -> Unit
) {
message.originalMessage?.let { originalMessage ->
val originalMessageIsFromLocal =
originalMessage.node.user.id == DataPacket.ID_LOCAL
val originalMessageNode =
if (originalMessageIsFromLocal) ourNode else originalMessage.node
OutlinedCard(
modifier = Modifier
.fillMaxWidth()
.clickable { onNavigateToOriginalMessage(originalMessage.packetId) },
colors = CardDefaults.cardColors(
containerColor = Color(originalMessageNode.colors.second).copy(alpha = 0.5f),
contentColor = Color(originalMessageNode.colors.first),
),
) {
Row(
modifier = Modifier.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.FormatQuote,
contentDescription = stringResource(R.string.reply), // Add to strings.xml
modifier = Modifier.size(14.dp), // Smaller icon
)
Spacer(Modifier.width(4.dp))
Column {
Text(
text = "${originalMessageNode.user.shortName} ${
originalMessageNode.user.longName
?: stringResource(R.string.unknown_username)
}",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = originalMessage.text, // Should not be null if isAReply is true
style = MaterialTheme.typography.bodySmall,
maxLines = 1, // Keep snippet brief
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
@OptIn(ExperimentalUuidApi::class)
@PreviewLightDark
@Composable
@ -288,15 +278,36 @@ private fun MessageItemPreview() {
replyId = null,
)
AppTheme {
MessageItem(
message = message,
node = message.node,
selected = false,
onClick = {},
onLongClick = {},
onStatusClick = {},
isConnected = true,
ourNode = message.node,
)
Column {
MessageItem(
message = message,
node = message.node,
selected = false,
onClick = {},
onLongClick = {},
onStatusClick = {},
isConnected = true,
ourNode = message.node,
)
MessageItem(
message = message.let { message ->
val originalMessage = message.copy(
replyId = message.packetId,
node = NodePreviewParameterProvider().values.last(),
text = "This is a reply to the original message."
)
message.copy(originalMessage = originalMessage)
},
node = message.node,
selected = false,
onClick = {},
onLongClick = {},
onStatusClick = {},
isConnected = true,
ourNode = message.node,
onNavigateToOriginalMessage = {}
)
}
}
}

View file

@ -28,21 +28,13 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
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.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Reply
import androidx.compose.material.icons.filled.EmojiEmotions
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@ -62,52 +54,8 @@ import androidx.compose.ui.unit.dp
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.components.EmojiPickerDialog
import com.geeksville.mesh.ui.common.theme.AppTheme
@Composable
fun ReactionButton(
modifier: Modifier = Modifier,
onSendReaction: (String) -> Unit = {},
) {
var showEmojiPickerDialog by remember { mutableStateOf(false) }
if (showEmojiPickerDialog) {
EmojiPickerDialog(
onConfirm = { selectedEmoji ->
showEmojiPickerDialog = false
onSendReaction(selectedEmoji)
},
onDismiss = { showEmojiPickerDialog = false }
)
}
IconButton(
modifier = modifier
.size(48.dp),
onClick = { showEmojiPickerDialog = true },
) {
Icon(
imageVector = Icons.Default.EmojiEmotions,
contentDescription = "emoji",
)
}
}
@Composable
fun ReplyButton(
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
) = IconButton(
modifier = modifier
.size(48.dp),
onClick = onClick,
content = {
Icon(
imageVector = Icons.AutoMirrored.Filled.Reply,
contentDescription = "reply",
)
}
)
@Composable
private fun ReactionItem(
emoji: String,
@ -182,21 +130,6 @@ fun ReactionRow(
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun MessageActions(
modifier: Modifier = Modifier,
onSendReaction: (String) -> Unit = {},
onSendReply: () -> Unit = {},
) {
Row(
modifier = modifier.wrapContentSize(),
) {
ReactionButton { onSendReaction(it) }
ReplyButton { onSendReply() }
}
}
fun reduceEmojis(emojis: List<String>): Map<String, Int> = emojis.groupingBy { it }.eachCount()
@Composable

View file

@ -24,8 +24,8 @@ import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.ElevatedAssistChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -37,7 +37,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.PaxcountProtos
import com.geeksville.mesh.TelemetryProtos
import com.geeksville.mesh.model.Node
@Composable
@ -53,11 +57,12 @@ fun NodeChip(
var menuExpanded by remember { mutableStateOf(false) }
val inputChipInteractionSource = remember { MutableInteractionSource() }
Box {
AssistChip(
ElevatedAssistChip(
modifier = modifier
.width(IntrinsicSize.Min)
.defaultMinSize(minHeight = 24.dp, minWidth = 72.dp),
colors = AssistChipDefaults.assistChipColors(
.defaultMinSize(minWidth = 72.dp),
elevation = AssistChipDefaults.elevatedAssistChipElevation(),
colors = AssistChipDefaults.elevatedAssistChipColors(
containerColor = Color(nodeColor),
labelColor = Color(textColor),
),
@ -97,3 +102,27 @@ fun NodeChip(
}
)
}
@Suppress("MagicNumber")
@Preview
@Composable
fun NodeChipPreview() {
val user = MeshProtos.User.newBuilder()
.setShortName("\uD83E\uDEE0")
.setLongName("John Doe")
.build()
val node = Node(
num = 13444,
user = user,
isIgnored = false,
paxcounter = PaxcountProtos.Paxcount.newBuilder().setBle(10).setWifi(5).build(),
environmentMetrics = TelemetryProtos.EnvironmentMetrics.newBuilder().setTemperature(25f)
.setRelativeHumidity(60f).build()
)
NodeChip(
node = node,
isThisNode = false,
isConnected = true,
onAction = {}
)
}