feat(ui): move quick chat menu to topBar (#2535)

This commit is contained in:
Pedro
2025-07-27 11:13:25 -03:00
committed by GitHub
parent 712ff946f5
commit f8aa6ebff5
2 changed files with 124 additions and 160 deletions

View File

@@ -46,6 +46,7 @@ import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.automirrored.filled.Reply
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.ArrowDownward
@@ -53,6 +54,7 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.filled.SpeakerNotesOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -101,9 +103,9 @@ import com.geeksville.mesh.ui.common.theme.AppTheme
import com.geeksville.mesh.ui.node.components.NodeKeyStatusIcon
import com.geeksville.mesh.ui.node.components.NodeMenuAction
import com.geeksville.mesh.ui.sharing.SharedContactDialog
import java.nio.charset.StandardCharsets
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.nio.charset.StandardCharsets
private const val MESSAGE_CHARACTER_LIMIT_BYTES = 200
private const val SNIPPET_CHARACTER_LIMIT = 50
@@ -136,12 +138,8 @@ internal fun MessageScreen(
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle(initialValue = false)
val channels by viewModel.channels.collectAsStateWithLifecycle()
val quickChatActions by
viewModel.quickChatActions.collectAsStateWithLifecycle(initialValue = emptyList())
val messages by
viewModel
.getMessagesFrom(contactKey)
.collectAsStateWithLifecycle(initialValue = emptyList())
val quickChatActions by viewModel.quickChatActions.collectAsStateWithLifecycle(initialValue = emptyList())
val messages by viewModel.getMessagesFrom(contactKey).collectAsStateWithLifecycle(initialValue = emptyList())
// UI State managed within this Composable
var replyingTo by rememberSaveable { mutableStateOf<Message?>(null) }
@@ -149,14 +147,14 @@ internal fun MessageScreen(
var sharedContact by rememberSaveable { mutableStateOf<Node?>(null) }
val selectedMessageIds = rememberSaveable { mutableStateOf(emptySet<Long>()) }
val messageInputState = rememberTextFieldState(message)
var showQuickChat by rememberSaveable { mutableStateOf(false) }
// Derived state, memoized for performance
val channelInfo =
remember(contactKey, channels) {
val index = contactKey.firstOrNull()?.digitToIntOrNull()
val id = contactKey.substring(1)
val name =
index?.let { channels.getChannel(it)?.name } // channels can be null initially
val name = index?.let { channels.getChannel(it)?.name } // channels can be null initially
Triple(index, id, name)
}
val (channelIndex, nodeId, rawChannelName) = channelInfo
@@ -180,8 +178,7 @@ internal fun MessageScreen(
val listState =
rememberLazyListState(
initialFirstVisibleItemIndex =
remember(messages) { messages.indexOfLast { !it.read }.coerceAtLeast(0) }
initialFirstVisibleItemIndex = remember(messages) { messages.indexOfLast { !it.read }.coerceAtLeast(0) },
)
val onEvent: (MessageScreenEvent) -> Unit =
@@ -227,13 +224,10 @@ internal fun MessageScreen(
is MessageScreenEvent.SetTitle -> viewModel.setTitle(event.title)
is MessageScreenEvent.NavigateToMessages -> navigateToMessages(event.contactKey)
is MessageScreenEvent.NavigateToNodeDetails ->
navigateToNodeDetails(event.nodeNum)
is MessageScreenEvent.NavigateToNodeDetails -> navigateToNodeDetails(event.nodeNum)
MessageScreenEvent.NavigateBack -> onNavigateBack()
is MessageScreenEvent.CopyToClipboard -> {
clipboardManager.nativeClipboard.setPrimaryClip(
ClipData.newPlainText(event.text, event.text)
)
clipboardManager.nativeClipboard.setPrimaryClip(ClipData.newPlainText(event.text, event.text))
selectedMessageIds.value = emptySet()
}
}
@@ -243,16 +237,12 @@ internal fun MessageScreen(
if (showDeleteDialog) {
DeleteMessageDialog(
count = selectedMessageIds.value.size,
onConfirm = {
onEvent(MessageScreenEvent.DeleteMessages(selectedMessageIds.value.toList()))
},
onConfirm = { onEvent(MessageScreenEvent.DeleteMessages(selectedMessageIds.value.toList())) },
onDismiss = { showDeleteDialog = false },
)
}
sharedContact?.let { contact ->
SharedContactDialog(contact = contact, onDismiss = { sharedContact = null })
}
sharedContact?.let { contact -> SharedContactDialog(contact = contact, onDismiss = { sharedContact = null }) }
Scaffold(
modifier = Modifier.fillMaxSize(),
@@ -291,6 +281,8 @@ internal fun MessageScreen(
onNavigateBack = { onEvent(MessageScreenEvent.NavigateBack) },
channels = channels,
channelIndexParam = channelIndex,
showQuickChat = showQuickChat,
onToggleQuickChat = { showQuickChat = !showQuickChat },
)
}
},
@@ -302,40 +294,32 @@ internal fun MessageScreen(
listState = listState,
messages = messages,
selectedIds = selectedMessageIds,
onUnreadChanged = { messageId ->
onEvent(MessageScreenEvent.ClearUnreadCount(messageId))
},
onSendReaction = { emoji, id ->
onEvent(MessageScreenEvent.SendReaction(emoji, id))
},
onUnreadChanged = { messageId -> onEvent(MessageScreenEvent.ClearUnreadCount(messageId)) },
onSendReaction = { emoji, id -> onEvent(MessageScreenEvent.SendReaction(emoji, id)) },
viewModel = viewModel,
contactKey = contactKey,
onReply = { message -> replyingTo = message },
onNodeMenuAction = { action ->
onEvent(MessageScreenEvent.HandleNodeMenuAction(action))
},
onNodeMenuAction = { action -> onEvent(MessageScreenEvent.HandleNodeMenuAction(action)) },
)
// Show FAB if we can scroll towards the newest messages (index 0).
if (listState.canScrollBackward) {
ScrollToBottomFab(coroutineScope, listState)
}
}
QuickChatRow(
enabled = isConnected,
actions = quickChatActions,
onClick = { action ->
handleQuickChatAction(
action = action,
messageInputState = messageInputState,
onSendMessage = { text -> onEvent(MessageScreenEvent.SendMessage(text)) },
)
},
)
ReplySnippet(
originalMessage = replyingTo,
onClearReply = { replyingTo = null },
ourNode = ourNode,
)
AnimatedVisibility(visible = showQuickChat) {
QuickChatRow(
enabled = isConnected,
actions = quickChatActions,
onClick = { action ->
handleQuickChatAction(
action = action,
messageInputState = messageInputState,
onSendMessage = { text -> onEvent(MessageScreenEvent.SendMessage(text)) },
)
},
)
}
ReplySnippet(originalMessage = replyingTo, onClearReply = { replyingTo = null }, ourNode = ourNode)
MessageInput(
isEnabled = isConnected,
textFieldState = messageInputState,
@@ -391,10 +375,10 @@ private fun ReplySnippet(originalMessage: Message?, onClearReply: () -> Unit, ou
Row(
modifier =
Modifier.fillMaxWidth()
.clip(RoundedCornerShape(24.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.padding(horizontal = 8.dp, vertical = 4.dp),
Modifier.fillMaxWidth()
.clip(RoundedCornerShape(24.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
@@ -404,11 +388,7 @@ private fun ReplySnippet(originalMessage: Message?, onClearReply: () -> Unit, ou
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text =
stringResource(
R.string.replying_to,
replyingToNodeUser?.shortName ?: unknownUserText,
),
text = stringResource(R.string.replying_to, replyingToNodeUser?.shortName ?: unknownUserText),
style = MaterialTheme.typography.labelMedium,
)
Text(
@@ -421,8 +401,7 @@ private fun ReplySnippet(originalMessage: Message?, onClearReply: () -> Unit, ou
IconButton(onClick = onClearReply) {
Icon(
Icons.Filled.Close,
contentDescription =
stringResource(R.string.cancel_reply), // Specific action
contentDescription = stringResource(R.string.cancel_reply), // Specific action
)
}
}
@@ -437,12 +416,10 @@ private fun ReplySnippet(originalMessage: Message?, onClearReply: () -> Unit, ou
* @return The ellipsized string.
* @receiver The string to ellipsize.
*/
private fun String.ellipsize(maxLength: Int): String =
if (length > maxLength) "${take(maxLength)}" else this
private fun String.ellipsize(maxLength: Int): String = if (length > maxLength) "${take(maxLength)}" else this
/**
* Handles a quick chat action, either appending its message to the input field or sending it
* directly.
* Handles a quick chat action, either appending its message to the input field or sending it directly.
*
* @param action The [QuickChatAction] to handle.
* @param messageInputState The [TextFieldState] of the message input field.
@@ -460,12 +437,12 @@ private fun handleQuickChatAction(
if (!originalText.contains(action.message)) {
val newText =
buildString {
append(originalText)
if (originalText.isNotEmpty() && !originalText.endsWith(' ')) {
append(' ')
}
append(action.message)
append(originalText)
if (originalText.isNotEmpty() && !originalText.endsWith(' ')) {
append(' ')
}
append(action.message)
}
.limitBytes(MESSAGE_CHARACTER_LIMIT_BYTES)
messageInputState.setTextAndPlaceCursorAtEnd(newText)
}
@@ -481,8 +458,7 @@ private fun handleQuickChatAction(
/**
* Truncates a string to ensure its UTF-8 byte representation does not exceed [maxBytes].
*
* This implementation iterates by characters and checks byte length to avoid splitting multi-byte
* characters.
* This implementation iterates by characters and checks byte length to avoid splitting multi-byte characters.
*
* @param maxBytes The maximum allowed byte length.
* @return The truncated string, or the original string if it's within the byte limit.
@@ -524,12 +500,8 @@ private fun DeleteMessageDialog(count: Int, onConfirm: () -> Unit, onDismiss: ()
shape = RoundedCornerShape(16.dp),
title = { Text(stringResource(R.string.delete_messages_title)) },
text = { Text(text = deleteMessagesString) },
confirmButton = {
TextButton(onClick = onConfirm) { Text(stringResource(R.string.delete)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
},
confirmButton = { TextButton(onClick = onConfirm) { Text(stringResource(R.string.delete)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
)
}
@@ -552,38 +524,31 @@ internal sealed class MessageMenuAction {
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ActionModeTopBar(selectedCount: Int, onAction: (MessageMenuAction) -> Unit) =
TopAppBar(
title = { Text(text = selectedCount.toString()) },
navigationIcon = {
IconButton(onClick = { onAction(MessageMenuAction.Dismiss) }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(id = R.string.clear_selection),
)
}
},
actions = {
IconButton(onClick = { onAction(MessageMenuAction.ClipboardCopy) }) {
Icon(
imageVector = Icons.Default.ContentCopy,
contentDescription = stringResource(id = R.string.copy),
)
}
IconButton(onClick = { onAction(MessageMenuAction.Delete) }) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(id = R.string.delete),
)
}
IconButton(onClick = { onAction(MessageMenuAction.SelectAll) }) {
Icon(
imageVector = Icons.Default.SelectAll,
contentDescription = stringResource(id = R.string.select_all),
)
}
},
)
private fun ActionModeTopBar(selectedCount: Int, onAction: (MessageMenuAction) -> Unit) = TopAppBar(
title = { Text(text = selectedCount.toString()) },
navigationIcon = {
IconButton(onClick = { onAction(MessageMenuAction.Dismiss) }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(id = R.string.clear_selection),
)
}
},
actions = {
IconButton(onClick = { onAction(MessageMenuAction.ClipboardCopy) }) {
Icon(imageVector = Icons.Default.ContentCopy, contentDescription = stringResource(id = R.string.copy))
}
IconButton(onClick = { onAction(MessageMenuAction.Delete) }) {
Icon(imageVector = Icons.Default.Delete, contentDescription = stringResource(id = R.string.delete))
}
IconButton(onClick = { onAction(MessageMenuAction.SelectAll) }) {
Icon(
imageVector = Icons.Default.SelectAll,
contentDescription = stringResource(id = R.string.select_all),
)
}
},
)
/**
* The default top app bar for the message screen.
@@ -604,32 +569,44 @@ private fun MessageTopBar(
onNavigateBack: () -> Unit,
channels: AppOnlyProtos.ChannelSet?,
channelIndexParam: Int?,
) =
TopAppBar(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis)
Spacer(modifier = Modifier.width(10.dp))
showQuickChat: Boolean,
onToggleQuickChat: () -> Unit,
) = TopAppBar(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis)
Spacer(modifier = Modifier.width(10.dp))
if (channels != null && channelIndexParam != null) {
SecurityIcon(channels, channelIndexParam)
}
if (channels != null && channelIndexParam != null) {
SecurityIcon(channels, channelIndexParam)
}
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(id = R.string.navigate_back),
)
}
},
actions = {
if (channelIndex == DataPacket.PKC_CHANNEL_INDEX) {
NodeKeyStatusIcon(hasPKC = true, mismatchKey = mismatchKey)
}
},
)
}
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(id = R.string.navigate_back),
)
}
},
actions = {
IconButton(onClick = onToggleQuickChat) {
Icon(
imageVector = if (showQuickChat) Icons.Filled.SpeakerNotesOff else Icons.AutoMirrored.Filled.Chat,
contentDescription =
if (showQuickChat) {
stringResource(id = R.string.quick_chat_hide)
} else {
stringResource(id = R.string.quick_chat_show)
},
)
}
if (channelIndex == DataPacket.PKC_CHANNEL_INDEX) {
NodeKeyStatusIcon(hasPKC = true, mismatchKey = mismatchKey)
}
},
)
/**
* A row of quick chat action buttons.
@@ -659,10 +636,7 @@ private fun QuickChatRow(
val allActions = remember(alertAction, actions) { listOf(alertAction) + actions }
LazyRow(
modifier = modifier.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
LazyRow(modifier = modifier.padding(vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
items(allActions, key = { it.uuid }) { action ->
Button(onClick = { onClick(action) }, enabled = enabled) { Text(text = action.name) }
}
@@ -676,8 +650,7 @@ private fun QuickChatRow(
* @param textFieldState The [TextFieldState] managing the input's text.
* @param modifier The modifier for this composable.
* @param maxByteSize The maximum allowed size of the message in bytes.
* @param onSendMessage Callback invoked when the send button is pressed or send IME action is
* triggered.
* @param onSendMessage Callback invoked when the send button is pressed or send IME action is triggered.
*/
@Suppress("LongMethod") // Due to multiple parts of the OutlinedTextField
@Composable
@@ -708,10 +681,7 @@ private fun MessageInput(
isError = isOverLimit,
placeholder = { Text(stringResource(R.string.type_a_message)) },
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.Sentences,
imeAction = ImeAction.Send,
),
KeyboardOptions(capitalization = KeyboardCapitalization.Sentences, imeAction = ImeAction.Send),
onKeyboardAction = {
if (canSend) {
onSendMessage()
@@ -723,11 +693,11 @@ private fun MessageInput(
text = "$currentByteLength/$maxByteSize",
style = MaterialTheme.typography.bodySmall,
color =
if (isOverLimit) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
if (isOverLimit) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.End,
)
@@ -754,25 +724,17 @@ private fun MessageInputPreview() {
AppTheme {
Surface {
Column(modifier = Modifier.padding(8.dp)) {
MessageInput(
isEnabled = true,
textFieldState = rememberTextFieldState("Hello"),
onSendMessage = {},
)
MessageInput(isEnabled = true, textFieldState = rememberTextFieldState("Hello"), onSendMessage = {})
Spacer(Modifier.size(16.dp))
MessageInput(
isEnabled = false,
textFieldState = rememberTextFieldState("Disabled"),
onSendMessage = {},
)
MessageInput(isEnabled = false, textFieldState = rememberTextFieldState("Disabled"), onSendMessage = {})
Spacer(Modifier.size(16.dp))
MessageInput(
isEnabled = true,
textFieldState =
rememberTextFieldState(
"A very long message that might exceed the byte limit " +
"and cause an error state display for the user to see clearly."
),
rememberTextFieldState(
"A very long message that might exceed the byte limit " +
"and cause an error state display for the user to see clearly.",
),
onSendMessage = {},
maxByteSize = 50, // Test with a smaller limit
)

View File

@@ -258,6 +258,8 @@
<string name="quick_chat_edit">Edit quick chat</string>
<string name="quick_chat_append">Append to message</string>
<string name="quick_chat_instant">Instantly send</string>
<string name="quick_chat_show">Show quick chat menu</string>
<string name="quick_chat_hide">Hide quick chat menu</string>
<string name="factory_reset">Factory reset</string>
<string name="factory_reset_description">This will clear all device configuration you have done.</string>
<string name="bluetooth_disabled">Bluetooth disabled</string>