Add CommandSuggestionsBox quick access (#146)

* Add CommandSuggestionsBox quick access

* Remove unnecessary evaluation

* Use a FilledTonalIconButton for the CommandSuggestionsBox quick access button

* fix import

* quick command instead of send

* simplify

---------

Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
This commit is contained in:
Héctor de Isidro
2025-08-06 01:25:15 +02:00
committed by GitHub
co-authored by callebtc
parent 1d1a91108e
commit b418aa3899
4 changed files with 90 additions and 71 deletions
@@ -44,27 +44,27 @@ fun ChatScreen(viewModel: ChatViewModel) {
val showMentionSuggestions by viewModel.showMentionSuggestions.observeAsState(false) val showMentionSuggestions by viewModel.showMentionSuggestions.observeAsState(false)
val mentionSuggestions by viewModel.mentionSuggestions.observeAsState(emptyList()) val mentionSuggestions by viewModel.mentionSuggestions.observeAsState(emptyList())
val showAppInfo by viewModel.showAppInfo.observeAsState(false) val showAppInfo by viewModel.showAppInfo.observeAsState(false)
var messageText by remember { mutableStateOf(TextFieldValue("")) } var messageText by remember { mutableStateOf(TextFieldValue("")) }
var showPasswordPrompt by remember { mutableStateOf(false) } var showPasswordPrompt by remember { mutableStateOf(false) }
var showPasswordDialog by remember { mutableStateOf(false) } var showPasswordDialog by remember { mutableStateOf(false) }
var passwordInput by remember { mutableStateOf("") } var passwordInput by remember { mutableStateOf("") }
// Show password dialog when needed // Show password dialog when needed
LaunchedEffect(showPasswordPrompt) { LaunchedEffect(showPasswordPrompt) {
showPasswordDialog = showPasswordPrompt showPasswordDialog = showPasswordPrompt
} }
val isConnected by viewModel.isConnected.observeAsState(false) val isConnected by viewModel.isConnected.observeAsState(false)
val passwordPromptChannel by viewModel.passwordPromptChannel.observeAsState(null) val passwordPromptChannel by viewModel.passwordPromptChannel.observeAsState(null)
// Determine what messages to show // Determine what messages to show
val displayMessages = when { val displayMessages = when {
selectedPrivatePeer != null -> privateChats[selectedPrivatePeer] ?: emptyList() selectedPrivatePeer != null -> privateChats[selectedPrivatePeer] ?: emptyList()
currentChannel != null -> channelMessages[currentChannel] ?: emptyList() currentChannel != null -> channelMessages[currentChannel] ?: emptyList()
else -> messages else -> messages
} }
// Use WindowInsets to handle keyboard properly // Use WindowInsets to handle keyboard properly
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
val headerHeight = 42.dp val headerHeight = 42.dp
@@ -78,7 +78,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
) { ) {
// Header spacer - creates space for the floating header // Header spacer - creates space for the floating header
Spacer(modifier = Modifier.height(headerHeight)) Spacer(modifier = Modifier.height(headerHeight))
// Messages area - takes up available space, will compress when keyboard appears // Messages area - takes up available space, will compress when keyboard appears
MessagesList( MessagesList(
messages = displayMessages, messages = displayMessages,
@@ -86,7 +86,6 @@ fun ChatScreen(viewModel: ChatViewModel) {
meshService = viewModel.meshService, meshService = viewModel.meshService,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
// Input area - stays at bottom // Input area - stays at bottom
ChatInputSection( ChatInputSection(
messageText = messageText, messageText = messageText,
@@ -125,7 +124,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
colorScheme = colorScheme colorScheme = colorScheme
) )
} }
// Floating header - positioned absolutely at top, ignores keyboard // Floating header - positioned absolutely at top, ignores keyboard
ChatFloatingHeader( ChatFloatingHeader(
headerHeight = headerHeight, headerHeight = headerHeight,
@@ -168,7 +167,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
targetOffsetX = { it }, targetOffsetX = { it },
animationSpec = tween(250, easing = EaseInCubic) animationSpec = tween(250, easing = EaseInCubic)
) + fadeOut(animationSpec = tween(250)), ) + fadeOut(animationSpec = tween(250)),
modifier = Modifier.zIndex(2f) modifier = Modifier.zIndex(2f)
) { ) {
SidebarOverlay( SidebarOverlay(
viewModel = viewModel, viewModel = viewModel,
@@ -177,7 +176,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
) )
} }
} }
// Dialogs // Dialogs
ChatDialogs( ChatDialogs(
showPasswordDialog = showPasswordDialog, showPasswordDialog = showPasswordDialog,
@@ -225,7 +224,6 @@ private fun ChatInputSection(
) { ) {
Column { Column {
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f)) HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f))
// Command suggestions box // Command suggestions box
if (showCommandSuggestions && commandSuggestions.isNotEmpty()) { if (showCommandSuggestions && commandSuggestions.isNotEmpty()) {
CommandSuggestionsBox( CommandSuggestionsBox(
@@ -247,7 +245,7 @@ private fun ChatInputSection(
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.2f)) HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.2f))
} }
MessageInput( MessageInput(
value = messageText, value = messageText,
onValueChange = onMessageTextChange, onValueChange = onMessageTextChange,
@@ -306,7 +304,7 @@ private fun ChatFloatingHeader(
) )
) )
} }
// Divider under header // Divider under header
HorizontalDivider( HorizontalDivider(
modifier = Modifier modifier = Modifier
@@ -337,7 +335,7 @@ private fun ChatDialogs(
onConfirm = onPasswordConfirm, onConfirm = onPasswordConfirm,
onDismiss = onPasswordDismiss onDismiss = onPasswordDismiss
) )
// App info dialog // App info dialog
AppInfoDialog( AppInfoDialog(
show = showAppInfo, show = showAppInfo,
@@ -327,9 +327,9 @@ class CommandProcessor(
} }
// MARK: - Command Autocomplete // MARK: - Command Autocomplete
fun updateCommandSuggestions(input: String) { fun updateCommandSuggestions(input: String) {
if (!input.startsWith("/") || input.length < 1) { if (!input.startsWith("/")) {
state.setShowCommandSuggestions(false) state.setShowCommandSuggestions(false)
state.setCommandSuggestions(emptyList()) state.setCommandSuggestions(emptyList())
return return
@@ -15,19 +15,23 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.bitchat.android.R
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
@@ -45,13 +49,13 @@ class SlashCommandVisualTransformation : VisualTransformation {
val slashCommandRegex = Regex("(/\\w+)(?=\\s|$)") val slashCommandRegex = Regex("(/\\w+)(?=\\s|$)")
val annotatedString = buildAnnotatedString { val annotatedString = buildAnnotatedString {
var lastIndex = 0 var lastIndex = 0
slashCommandRegex.findAll(text.text).forEach { match -> slashCommandRegex.findAll(text.text).forEach { match ->
// Add text before the match // Add text before the match
if (match.range.first > lastIndex) { if (match.range.first > lastIndex) {
append(text.text.substring(lastIndex, match.range.first)) append(text.text.substring(lastIndex, match.range.first))
} }
// Add the styled slash command // Add the styled slash command
withStyle( withStyle(
style = SpanStyle( style = SpanStyle(
@@ -63,16 +67,16 @@ class SlashCommandVisualTransformation : VisualTransformation {
) { ) {
append(match.value) append(match.value)
} }
lastIndex = match.range.last + 1 lastIndex = match.range.last + 1
} }
// Add remaining text // Add remaining text
if (lastIndex < text.text.length) { if (lastIndex < text.text.length) {
append(text.text.substring(lastIndex)) append(text.text.substring(lastIndex))
} }
} }
return TransformedText( return TransformedText(
text = annotatedString, text = annotatedString,
offsetMapping = OffsetMapping.Identity offsetMapping = OffsetMapping.Identity
@@ -162,7 +166,8 @@ fun MessageInput(
Row( Row(
modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp), // Reduced padding modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp), // Reduced padding
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
// Text input with placeholder // Text input with placeholder
Box( Box(
@@ -205,48 +210,64 @@ fun MessageInput(
Spacer(modifier = Modifier.width(8.dp)) // Reduced spacing Spacer(modifier = Modifier.width(8.dp)) // Reduced spacing
// Send button with enabled/disabled state // Command quick access button
IconButton( if (value.text.isEmpty()) {
onClick = { if (hasText) onSend() }, // Only execute if there's text FilledTonalIconButton(
enabled = hasText, // Enable only when there's text onClick = {
modifier = Modifier.size(32.dp) onValueChange(TextFieldValue(text = "/", selection = TextRange("/".length)))
) { },
Box( modifier = Modifier.size(32.dp)
modifier = Modifier
.size(30.dp)
.background(
color = if (!hasText) {
// Disabled state - muted grey
colorScheme.onSurface.copy(alpha = 0.3f)
} else if (selectedPrivatePeer != null || currentChannel != null) {
// Orange for both private messages and channels when enabled
Color(0xFFFF9500).copy(alpha = 0.75f)
} else if (colorScheme.background == Color.Black) {
Color(0xFF00FF00).copy(alpha = 0.75f) // Bright green for dark theme
} else {
Color(0xFF008000).copy(alpha = 0.75f) // Dark green for light theme
},
shape = CircleShape
),
contentAlignment = Alignment.Center
) { ) {
Icon( Text(
imageVector = Icons.Filled.ArrowUpward, text = "/",
contentDescription = "Send message", textAlign = TextAlign.Center
modifier = Modifier.size(20.dp),
tint = if (!hasText) {
// Disabled state - muted grey icon
colorScheme.onSurface.copy(alpha = 0.5f)
} else if (selectedPrivatePeer != null || currentChannel != null) {
// Black arrow on orange for both private and channel modes
Color.Black
} else if (colorScheme.background == Color.Black) {
Color.Black // Black arrow on bright green in dark theme
} else {
Color.White // White arrow on dark green in light theme
}
) )
} }
} else {
// Send button with enabled/disabled state
IconButton(
onClick = { if (hasText) onSend() }, // Only execute if there's text
enabled = hasText, // Enable only when there's text
modifier = Modifier.size(32.dp)
) {
// Update send button to match input field colors
Box(
modifier = Modifier
.size(30.dp)
.background(
color = if (!hasText) {
// Disabled state - muted grey
colorScheme.onSurface.copy(alpha = 0.3f)
} else if (selectedPrivatePeer != null || currentChannel != null) {
// Orange for both private messages and channels when enabled
Color(0xFFFF9500).copy(alpha = 0.75f)
} else if (colorScheme.background == Color.Black) {
Color(0xFF00FF00).copy(alpha = 0.75f) // Bright green for dark theme
} else {
Color(0xFF008000).copy(alpha = 0.75f) // Dark green for light theme
},
shape = CircleShape
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.ArrowUpward,
contentDescription = stringResource(id = R.string.send_message),
modifier = Modifier.size(20.dp),
tint = if (!hasText) {
// Disabled state - muted grey icon
colorScheme.onSurface.copy(alpha = 0.5f)
} else if (selectedPrivatePeer != null || currentChannel != null) {
// Black arrow on orange for both private and channel modes
Color.Black
} else if (colorScheme.background == Color.Black) {
Color.Black // Black arrow on bright green in dark theme
} else {
Color.White // White arrow on dark green in light theme
}
)
}
}
} }
} }
} }
@@ -258,9 +279,10 @@ fun CommandSuggestionsBox(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
Column( Column(
modifier = modifier modifier = modifier
.verticalScroll(rememberScrollState())
.background(colorScheme.surface) .background(colorScheme.surface)
.border(1.dp, colorScheme.outline.copy(alpha = 0.3f), RoundedCornerShape(4.dp)) .border(1.dp, colorScheme.outline.copy(alpha = 0.3f), RoundedCornerShape(4.dp))
.padding(vertical = 8.dp) .padding(vertical = 8.dp)
@@ -280,14 +302,15 @@ fun CommandSuggestionItem(
onClick: () -> Unit onClick: () -> Unit
) { ) {
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { onClick() } .clickable { onClick() }
.padding(horizontal = 12.dp, vertical = 3.dp) .padding(horizontal = 12.dp, vertical = 3.dp)
.background(Color.Gray.copy(alpha = 0.1f)), .background(Color.Gray.copy(alpha = 0.1f)),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
// Show all aliases together // Show all aliases together
val allCommands = if (suggestion.aliases.isNotEmpty()) { val allCommands = if (suggestion.aliases.isNotEmpty()) {
@@ -295,7 +318,7 @@ fun CommandSuggestionItem(
} else { } else {
listOf(suggestion.command) listOf(suggestion.command)
} }
Text( Text(
text = allCommands.joinToString(", "), text = allCommands.joinToString(", "),
style = MaterialTheme.typography.bodySmall.copy( style = MaterialTheme.typography.bodySmall.copy(
@@ -305,10 +328,9 @@ fun CommandSuggestionItem(
color = colorScheme.primary, color = colorScheme.primary,
fontSize = 11.sp fontSize = 11.sp
) )
// Show syntax if any // Show syntax if any
suggestion.syntax?.let { syntax -> suggestion.syntax?.let { syntax ->
Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = syntax, text = syntax,
style = MaterialTheme.typography.bodySmall.copy( style = MaterialTheme.typography.bodySmall.copy(
@@ -318,9 +340,7 @@ fun CommandSuggestionItem(
fontSize = 10.sp fontSize = 10.sp
) )
} }
Spacer(modifier = Modifier.weight(1f))
// Show description // Show description
Text( Text(
text = suggestion.description, text = suggestion.description,
+1
View File
@@ -10,6 +10,7 @@
<string name="join_channel">Join Channel</string> <string name="join_channel">Join Channel</string>
<string name="leave_channel">Leave</string> <string name="leave_channel">Leave</string>
<string name="send_message">Send</string> <string name="send_message">Send</string>
<string name="show_commands">Show commands</string>
<string name="back">Back</string> <string name="back">Back</string>
<string name="people">People</string> <string name="people">People</string>
<string name="channels">Channels</string> <string name="channels">Channels</string>