diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageActions.kt b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageActions.kt
new file mode 100644
index 000000000..65350205b
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageActions.kt
@@ -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 .
+ */
+
+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
+ )
+ }
+}
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 ca9b43f80..bf2ca1c58 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
@@ -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 = {}
+ )
+ }
}
}
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 0b8b47654..4742a4461 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
@@ -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): Map = emojis.groupingBy { it }.eachCount()
@Composable
diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/components/NodeChip.kt b/app/src/main/java/com/geeksville/mesh/ui/node/components/NodeChip.kt
index 5ae7cf157..a129e39de 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/node/components/NodeChip.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/node/components/NodeChip.kt
@@ -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 = {}
+ )
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 8aac4f9bb..7dc143d44 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -696,4 +696,5 @@
Modules unlocked
Remote
(%1$d online / %2$d total)
+ React