diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt index fa696d3f2..79a45b5e2 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -22,6 +22,8 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.recalculateWindowInsets import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -196,7 +198,7 @@ fun MainScreen( val currentDestination = navController.currentBackStackEntryAsState().value?.destination val topLevelDestination = TopLevelDestination.fromNavDestination(currentDestination) NavigationSuiteScaffold( - modifier = Modifier.safeDrawingPadding(), + modifier = Modifier.fillMaxSize(), navigationSuiteItems = { TopLevelDestination.entries.forEach { destination -> val isSelected = destination == topLevelDestination @@ -292,6 +294,11 @@ fun MainScreen( }, ) NavGraph( + modifier = Modifier + .fillMaxSize() + .recalculateWindowInsets() + .safeDrawingPadding() + .imePadding(), uIViewModel = uIViewModel, bluetoothViewModel = bluetoothViewModel, navController = navController, diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt b/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt index cc8cea91a..e4aefd290 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt @@ -20,18 +20,23 @@ package com.geeksville.mesh.ui.message import android.content.ClipData import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +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.Reply @@ -53,7 +58,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -64,23 +68,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.onFocusEvent -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.isAltPressed -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.onKeyEvent -import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewLightDark @@ -116,6 +110,9 @@ internal fun MessageScreen( val coroutineScope = rememberCoroutineScope() val clipboardManager = LocalClipboard.current + val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle() + val isConnected by viewModel.isConnected.collectAsStateWithLifecycle(false) + val channelIndex = contactKey[0].digitToIntOrNull() val nodeId = contactKey.substring(1) val channels by viewModel.channels.collectAsStateWithLifecycle() @@ -140,16 +137,11 @@ internal fun MessageScreen( val selectedIds = rememberSaveable { mutableStateOf(emptySet()) } val inSelectionMode by remember { derivedStateOf { selectedIds.value.isNotEmpty() } } - val connState by viewModel.connectionState.collectAsStateWithLifecycle() val quickChat by viewModel.quickChatActions.collectAsStateWithLifecycle() val messages by viewModel.getMessagesFrom(contactKey).collectAsStateWithLifecycle(listOf()) - val messageInput = rememberSaveable( - key = contactKey, // Use contactKey as key so state resets when switching conversations - stateSaver = TextFieldValue.Saver - ) { - mutableStateOf(TextFieldValue(message)) - } + val messageInput = rememberTextFieldState(message) + var replyingTo by remember { mutableStateOf(null) } var showDeleteDialog by remember { mutableStateOf(false) } @@ -164,8 +156,16 @@ internal fun MessageScreen( onDismiss = { showDeleteDialog = false } ) } + var sharedContact: Node? by remember { mutableStateOf(null) } + if (sharedContact != null) { + SharedContactDialog( + contact = sharedContact, + onDismiss = { sharedContact = null } + ) + } Scaffold( + modifier = Modifier.fillMaxSize(), topBar = { if (inSelectionMode) { ActionModeTopBar(selectedIds.value) { action -> @@ -198,96 +198,20 @@ internal fun MessageScreen( MessageTopBar(title, channelIndex, mismatchKey, onNavigateBack) } }, - bottomBar = { - val isConnected = connState.isConnected() - Column( - modifier = Modifier - .padding(start = 8.dp, end = 8.dp, bottom = 4.dp), - ) { - QuickChatRow(isConnected, quickChat) { action -> - if (action.mode == QuickChatAction.Mode.Append) { - val originalText = messageInput.value.text - if (!originalText.contains(action.message)) { - val needsSpace = - !originalText.endsWith(' ') && originalText.isNotEmpty() - val newText = buildString { - append(originalText) - if (needsSpace) append(' ') - append(action.message) - }.take(MESSAGE_CHARACTER_LIMIT) - messageInput.value = TextFieldValue(newText, TextRange(newText.length)) - } - } else { - viewModel.sendMessage(action.message, contactKey) - } - } - - AnimatedVisibility(visible = replyingTo != null) { - val fromLocal = replyingTo?.node?.user?.id == DataPacket.ID_LOCAL - - val replyingToNode = if (fromLocal) { - viewModel.ourNodeInfo.value - } else { - replyingTo?.node - } - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(24.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - Icons.AutoMirrored.Default.Reply, - contentDescription = stringResource(R.string.reply) - ) - Spacer(Modifier.width(8.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - "Replying to ${replyingToNode?.user?.shortName ?: stringResource(R.string.unknown)}", - style = MaterialTheme.typography.labelMedium - ) - Text( - replyingTo?.text?.take(SNIPPET_CHARACTER_LIMIT) - ?.let { if (it.length == SNIPPET_CHARACTER_LIMIT) "$it…" else it } ?: "", // Snippet - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - IconButton(onClick = { - replyingTo = null - }) { // ViewModel function to set replyingToMessageState to null - Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.cancel)) - } - } - } - TextInput(isConnected, messageInput) { message -> - replyingTo?.let { - viewModel.sendMessage(message, contactKey, it.packetId) - replyingTo = null - } ?: viewModel.sendMessage(message, contactKey) - // Clear the text input after sending the message and updating all state - messageInput.value = TextFieldValue("") - } - } - } ) { padding -> - if (messages.isNotEmpty()) { - var sharedContact: Node? by remember { mutableStateOf(null) } - if (sharedContact != null) { - SharedContactDialog( - contact = sharedContact, - onDismiss = { sharedContact = null } - ) - } - + Column(Modifier.padding(padding)) { MessageList( - modifier = Modifier.padding(padding), + modifier = Modifier.weight(1f, fill = true), messages = messages, selectedIds = selectedIds, onUnreadChanged = { viewModel.clearUnreadCount(contactKey, it) }, - onSendReaction = { emoji, id -> viewModel.sendReaction(emoji, id, contactKey) }, + onSendReaction = { emoji, id -> + viewModel.sendReaction( + emoji, + id, + contactKey + ) + }, viewModel = viewModel, contactKey = contactKey, onReply = { replyingTo = it }, @@ -307,10 +231,108 @@ internal fun MessageScreen( } }, ) + QuickChatRow( + enabled = isConnected, + actions = quickChat, + onClick = { action -> + handleQuickChatAction(action, messageInput, viewModel, contactKey) + } + ) + ReplySnippet(replyingTo, { replyingTo = null }, ourNode) + TextInput(isConnected, messageInput) { + val message = messageInput.text.toString().trim() + if (message.isNotEmpty()) { + replyingTo?.let { + viewModel.sendMessage(message, contactKey, it.packetId) + replyingTo = null + } ?: viewModel.sendMessage(message, contactKey) + // Clear the text input after sending the message and updating all state + messageInput.clearText() + } + } } } } +@Composable +private fun ReplySnippet( + originalMessage: Message?, + clearReply: () -> Unit = {}, + ourNode: Node? +) { + AnimatedVisibility(visible = originalMessage != null) { + val fromLocal = originalMessage?.node?.user?.id == DataPacket.ID_LOCAL + + val replyingToNode = if (fromLocal) { + ourNode + } else { + originalMessage?.node + } + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + IconButton( + enabled = false, + onClick = {} + ) { + Icon( + Icons.AutoMirrored.Default.Reply, + contentDescription = stringResource(R.string.reply) + ) + } + Text( + "Replying to ${replyingToNode?.user?.shortName ?: stringResource(R.string.unknown)}", + style = MaterialTheme.typography.labelMedium + ) + Text( + modifier = Modifier.weight(1f, fill = true), + text = originalMessage?.text?.take(SNIPPET_CHARACTER_LIMIT) + ?.let { if (it.length == SNIPPET_CHARACTER_LIMIT) "$it…" else it } + ?: "", // Snippet + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + IconButton( + onClick = clearReply + ) { // ViewModel function to set replyingToMessageState to null + Icon( + Icons.Filled.Close, + contentDescription = stringResource(R.string.cancel) + ) + } + } + } +} + +private fun handleQuickChatAction( + action: QuickChatAction, + messageInput: TextFieldState, + viewModel: UIViewModel, + contactKey: String +) { + if (action.mode == QuickChatAction.Mode.Append) { + val originalText = messageInput.text + if (!originalText.contains(action.message)) { + val needsSpace = + !originalText.endsWith(' ') && originalText.isNotEmpty() + val newText = buildString { + append(originalText) + if (needsSpace) append(' ') + append(action.message) + }.take(MESSAGE_CHARACTER_LIMIT) + messageInput.setTextAndPlaceCursorAtEnd(newText) + } + } else { + viewModel.sendMessage(action.message, contactKey) + } +} + @Composable private fun DeleteMessageDialog( size: Int, @@ -412,9 +434,9 @@ private fun MessageTopBar( @Composable private fun QuickChatRow( + modifier: Modifier = Modifier, enabled: Boolean, actions: List, - modifier: Modifier = Modifier, onClick: (QuickChatAction) -> Unit ) { val alertAction = QuickChatAction( @@ -441,68 +463,58 @@ private fun QuickChatRow( } } +private const val ROUNDED_CORNER_PERCENT = 100 + @Suppress("LongMethod") @Composable private fun TextInput( enabled: Boolean, - message: MutableState, + message: TextFieldState, modifier: Modifier = Modifier, maxSize: Int = MESSAGE_CHARACTER_LIMIT, - onClick: (String) -> Unit = {} -) = Column(modifier) { - val focusManager = LocalFocusManager.current - var isFocused by remember { mutableStateOf(false) } + onSendMessage: () -> Unit = {} +) { + val isOverLimit = message.text.length > maxSize + val isValid = !isOverLimit && message.text.isNotEmpty() OutlinedTextField( - value = message.value, - onValueChange = { - if (it.text.toByteArray().size <= maxSize) { - message.value = it - } - }, - modifier = Modifier - .fillMaxWidth() - .onFocusEvent { isFocused = it.isFocused } - .onKeyEvent { event -> - if (event.type == KeyEventType.KeyDown && - event.key == Key.Enter && - event.isAltPressed - ) { - val str = message.value.text.trim() - if (str.isNotEmpty()) { - onClick(str) - } - true // Consume the event - } else { - false // Do not consume other key events - } - }, + modifier = modifier.fillMaxWidth(), + state = message, + lineLimits = TextFieldLineLimits.SingleLine, + label = { Text(stringResource(R.string.send_text)) }, enabled = enabled, - placeholder = { Text(stringResource(id = R.string.send_text)) }, + shape = RoundedCornerShape(ROUNDED_CORNER_PERCENT), + isError = isOverLimit, + placeholder = { Text(stringResource(R.string.send_text)) }, keyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.Sentences, imeAction = ImeAction.Send ), - keyboardActions = KeyboardActions( - onSend = { - val str = message.value.text.trim() - if (str.isNotEmpty()) { - onClick(str) - } + onKeyboardAction = { + if (isValid) { + onSendMessage() } - ), - maxLines = 3, - shape = RoundedCornerShape(24.dp), + }, + supportingText = { + Text( + text = "${message.text.length}/$maxSize", + style = MaterialTheme.typography.bodySmall, + color = if (isOverLimit) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + inputTransformation = { + if (this.length > maxSize) { + this.replace(maxSize, this.length, "") + } + }, trailingIcon = { IconButton( onClick = { - val str = message.value.text.trim() - if (str.isNotEmpty()) { - onClick(str) - focusManager.clearFocus() + if (isValid) { + onSendMessage() } }, modifier = Modifier.size(48.dp), - enabled = enabled, + enabled = enabled && isValid, ) { Icon( imageVector = Icons.AutoMirrored.Default.Send, @@ -511,15 +523,6 @@ private fun TextInput( } } ) - if (isFocused) { - Text( - text = "${message.value.text.toByteArray().size}/$maxSize", - style = MaterialTheme.typography.bodySmall, - modifier = Modifier - .align(Alignment.End) - .padding(top = 4.dp, end = 72.dp) - ) - } } @PreviewLightDark @@ -530,12 +533,12 @@ private fun TextInputPreview() { Column { TextInput( enabled = true, - message = remember { mutableStateOf(TextFieldValue("")) }, + message = rememberTextFieldState("Hello"), ) Spacer(Modifier.size(16.dp)) TextInput( enabled = true, - message = remember { mutableStateOf(TextFieldValue("Hello")) }, + message = rememberTextFieldState("Hello"), ) } } 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 7a4352c4f..7bcf3bbea 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 @@ -28,8 +28,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -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.material3.Card @@ -122,7 +120,7 @@ internal fun MessageItem( modifier = Modifier .fillMaxWidth(), ) { - ReplyingTo( + OriginalMessageSnippet( message = message, ourNode = ourNode, cardColors = cardColors, @@ -214,7 +212,7 @@ internal fun MessageItem( } @Composable -private fun ReplyingTo( +private fun OriginalMessageSnippet( message: Message, ourNode: Node, cardColors: CardColors = CardDefaults.cardColors(), @@ -233,32 +231,27 @@ private fun ReplyingTo( ) { Row( modifier = Modifier.padding(horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) ) { 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, - ) - } + Text( + text = "${originalMessageNode.user.shortName}", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + modifier = Modifier.weight(1f, fill = true), + text = originalMessage.text, // Should not be null if isAReply is true + style = MaterialTheme.typography.bodySmall, + maxLines = 1, // Keep snippet brief + overflow = TextOverflow.Ellipsis, + ) } } }