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