UI changes and fixes (#301)

* country -> region, region –> province

* revise clicks

* fix longpress for mention etc

* scroll down button wip

* fix distance

* fix button

* fix header icon
This commit is contained in:
callebtc
2025-08-23 21:18:36 +02:00
committed by GitHub
parent 7106be4b07
commit 37c8c79310
3 changed files with 86 additions and 46 deletions
@@ -487,30 +487,6 @@ private fun MainHeader(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp) horizontalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
// Unread indicator (like iOS)
if (hasUnreadPrivateMessages.isNotEmpty()) {
Button(
onClick = {
// Open most relevant private chat (first unread)
val firstUnread = hasUnreadPrivateMessages.firstOrNull()
if (firstUnread != null) {
viewModel.startPrivateChat(firstUnread)
}
},
colors = ButtonDefaults.buttonColors(
containerColor = Color.Transparent,
contentColor = Color(0xFFFF9500)
),
contentPadding = PaddingValues(4.dp)
) {
Icon(
imageVector = Icons.Filled.Email,
contentDescription = "Open unread private chat",
modifier = Modifier.size(12.dp),
tint = Color(0xFFFF9500)
)
}
}
// Location channels button (matching iOS implementation) // Location channels button (matching iOS implementation)
LocationChannelsButton( LocationChannelsButton(
@@ -9,6 +9,12 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
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.Alignment
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.IconButton
import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -55,6 +61,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
var selectedUserForSheet by remember { mutableStateOf("") } var selectedUserForSheet by remember { mutableStateOf("") }
var selectedMessageForSheet by remember { mutableStateOf<BitchatMessage?>(null) } var selectedMessageForSheet by remember { mutableStateOf<BitchatMessage?>(null) }
var forceScrollToBottom by remember { mutableStateOf(false) } var forceScrollToBottom by remember { mutableStateOf(false) }
var isScrolledUp by remember { mutableStateOf(false) }
// Show password dialog when needed // Show password dialog when needed
LaunchedEffect(showPasswordPrompt) { LaunchedEffect(showPasswordPrompt) {
@@ -100,6 +107,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
meshService = viewModel.meshService, meshService = viewModel.meshService,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
forceScrollToBottom = forceScrollToBottom, forceScrollToBottom = forceScrollToBottom,
onScrolledUpChanged = { isUp -> isScrolledUp = isUp },
onNicknameClick = { fullSenderName -> onNicknameClick = { fullSenderName ->
// Single click - mention user in text input // Single click - mention user in text input
val currentText = messageText.text val currentText = messageText.text
@@ -220,6 +228,35 @@ fun ChatScreen(viewModel: ChatViewModel) {
) )
} }
// Scroll-to-bottom floating button
AnimatedVisibility(
visible = isScrolledUp && !showSidebar,
enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(),
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = 64.dp)
.zIndex(1.5f)
.windowInsetsPadding(WindowInsets.navigationBars)
.windowInsetsPadding(WindowInsets.ime)
) {
Surface(
shape = CircleShape,
color = colorScheme.background,
tonalElevation = 3.dp,
shadowElevation = 6.dp,
border = BorderStroke(2.dp, Color(0xFF00C851))
) {
IconButton(onClick = { forceScrollToBottom = !forceScrollToBottom }) {
Icon(
imageVector = Icons.Filled.ArrowDownward,
contentDescription = "Scroll to bottom",
tint = Color(0xFF00C851)
)
}
}
}
AnimatedVisibility( AnimatedVisibility(
visible = showSidebar, visible = showSidebar,
enter = slideInHorizontally( enter = slideInHorizontally(
@@ -2,22 +2,27 @@ package com.bitchat.android.ui
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment 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.input.pointer.pointerInput
import androidx.compose.ui.text.TextLayoutResult
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.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 androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.mesh.BluetoothMeshService
@@ -36,6 +41,7 @@ fun MessagesList(
meshService: BluetoothMeshService, meshService: BluetoothMeshService,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
forceScrollToBottom: Boolean = false, forceScrollToBottom: Boolean = false,
onScrolledUpChanged: ((Boolean) -> Unit)? = null,
onNicknameClick: ((String) -> Unit)? = null, onNicknameClick: ((String) -> Unit)? = null,
onMessageLongPress: ((BitchatMessage) -> Unit)? = null onMessageLongPress: ((BitchatMessage) -> Unit)? = null
) { ) {
@@ -63,9 +69,20 @@ fun MessagesList(
} }
} }
// Track whether user has scrolled away from the latest messages
val isAtLatest by remember {
derivedStateOf {
val firstVisibleIndex = listState.layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: -1
firstVisibleIndex <= 2
}
}
LaunchedEffect(isAtLatest) {
onScrolledUpChanged?.invoke(!isAtLatest)
}
// Force scroll to bottom when requested (e.g., when user sends a message) // Force scroll to bottom when requested (e.g., when user sends a message)
LaunchedEffect(forceScrollToBottom) { LaunchedEffect(forceScrollToBottom) {
if (forceScrollToBottom && messages.isNotEmpty()) { if (messages.isNotEmpty()) {
// With reverseLayout=true and reversed data, latest is at index 0 // With reverseLayout=true and reversed data, latest is at index 0
listState.animateScrollToItem(0) listState.animateScrollToItem(0)
} }
@@ -181,42 +198,52 @@ private fun MessageTextWithClickableNicknames(
message.sender.startsWith("$currentUserNickname#") message.sender.startsWith("$currentUserNickname#")
if (!isSelf && (onNicknameClick != null || onMessageLongPress != null)) { if (!isSelf && (onNicknameClick != null || onMessageLongPress != null)) {
// Use Text with combinedClickable for nickname interactions and message long press val haptic = LocalHapticFeedback.current
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
Text( Text(
text = annotatedText, text = annotatedText,
modifier = modifier.combinedClickable( modifier = modifier.pointerInput(message) {
onClick = { detectTapGestures(
// We can't get the click offset here, so we'll handle the first nickname onTap = { position ->
val nicknameAnnotations = annotatedText.getStringAnnotations( val layout = textLayoutResult ?: return@detectTapGestures
tag = "nickname_click", val offset = layout.getOffsetForPosition(position)
start = 0, val nicknameAnnotations = annotatedText.getStringAnnotations(
end = annotatedText.length tag = "nickname_click",
) start = offset,
if (nicknameAnnotations.isNotEmpty()) { end = offset
val nickname = nicknameAnnotations.first().item )
onNicknameClick?.invoke(nickname) if (nicknameAnnotations.isNotEmpty()) {
val nickname = nicknameAnnotations.first().item
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onNicknameClick?.invoke(nickname)
}
},
onLongPress = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onMessageLongPress?.invoke(message)
} }
}, )
onLongClick = { },
// Always use message long press - contains all necessary information
onMessageLongPress?.invoke(message)
}
),
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
softWrap = true, softWrap = true,
overflow = TextOverflow.Visible, overflow = TextOverflow.Visible,
style = androidx.compose.ui.text.TextStyle( style = androidx.compose.ui.text.TextStyle(
color = colorScheme.onSurface color = colorScheme.onSurface
) ),
onTextLayout = { result -> textLayoutResult = result }
) )
} else { } else {
// Use regular text with message long press support for own messages // Use regular text with message long press support for own messages
val haptic = LocalHapticFeedback.current
Text( Text(
text = annotatedText, text = annotatedText,
modifier = if (onMessageLongPress != null) { modifier = if (onMessageLongPress != null) {
modifier.combinedClickable( modifier.combinedClickable(
onClick = { /* No action for own messages */ }, onClick = { /* No action for own messages */ },
onLongClick = { onMessageLongPress.invoke(message) } onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onMessageLongPress.invoke(message)
}
) )
} else { } else {
modifier modifier