From 37c8c79310ada48272e042fca4584019f5c2f334 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 23 Aug 2025 21:18:36 +0200 Subject: [PATCH] UI changes and fixes (#301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * country -> region, region –> province * revise clicks * fix longpress for mention etc * scroll down button wip * fix distance * fix button * fix header icon --- .../java/com/bitchat/android/ui/ChatHeader.kt | 24 ------- .../java/com/bitchat/android/ui/ChatScreen.kt | 37 ++++++++++ .../bitchat/android/ui/MessageComponents.kt | 71 +++++++++++++------ 3 files changed, 86 insertions(+), 46 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index f31683b7..7c5a1c95 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -487,30 +487,6 @@ private fun MainHeader( verticalAlignment = Alignment.CenterVertically, 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) LocationChannelsButton( diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index b8db76b6..8638c094 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -9,6 +9,12 @@ import androidx.compose.runtime.* import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier 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.input.TextFieldValue import androidx.compose.ui.unit.dp @@ -55,6 +61,7 @@ fun ChatScreen(viewModel: ChatViewModel) { var selectedUserForSheet by remember { mutableStateOf("") } var selectedMessageForSheet by remember { mutableStateOf(null) } var forceScrollToBottom by remember { mutableStateOf(false) } + var isScrolledUp by remember { mutableStateOf(false) } // Show password dialog when needed LaunchedEffect(showPasswordPrompt) { @@ -100,6 +107,7 @@ fun ChatScreen(viewModel: ChatViewModel) { meshService = viewModel.meshService, modifier = Modifier.weight(1f), forceScrollToBottom = forceScrollToBottom, + onScrolledUpChanged = { isUp -> isScrolledUp = isUp }, onNicknameClick = { fullSenderName -> // Single click - mention user in text input 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( visible = showSidebar, enter = slideInHorizontally( diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index 684d9a3f..75a78fcc 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -2,22 +2,27 @@ package com.bitchat.android.ui import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.text.ClickableText + import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp 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.DeliveryStatus import com.bitchat.android.mesh.BluetoothMeshService @@ -36,6 +41,7 @@ fun MessagesList( meshService: BluetoothMeshService, modifier: Modifier = Modifier, forceScrollToBottom: Boolean = false, + onScrolledUpChanged: ((Boolean) -> Unit)? = null, onNicknameClick: ((String) -> 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) LaunchedEffect(forceScrollToBottom) { - if (forceScrollToBottom && messages.isNotEmpty()) { + if (messages.isNotEmpty()) { // With reverseLayout=true and reversed data, latest is at index 0 listState.animateScrollToItem(0) } @@ -181,42 +198,52 @@ private fun MessageTextWithClickableNicknames( message.sender.startsWith("$currentUserNickname#") 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(null) } Text( text = annotatedText, - modifier = modifier.combinedClickable( - onClick = { - // We can't get the click offset here, so we'll handle the first nickname - val nicknameAnnotations = annotatedText.getStringAnnotations( - tag = "nickname_click", - start = 0, - end = annotatedText.length - ) - if (nicknameAnnotations.isNotEmpty()) { - val nickname = nicknameAnnotations.first().item - onNicknameClick?.invoke(nickname) + modifier = modifier.pointerInput(message) { + detectTapGestures( + onTap = { position -> + val layout = textLayoutResult ?: return@detectTapGestures + val offset = layout.getOffsetForPosition(position) + val nicknameAnnotations = annotatedText.getStringAnnotations( + tag = "nickname_click", + start = offset, + end = offset + ) + 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, softWrap = true, overflow = TextOverflow.Visible, style = androidx.compose.ui.text.TextStyle( color = colorScheme.onSurface - ) + ), + onTextLayout = { result -> textLayoutResult = result } ) } else { // Use regular text with message long press support for own messages + val haptic = LocalHapticFeedback.current Text( text = annotatedText, modifier = if (onMessageLongPress != null) { modifier.combinedClickable( onClick = { /* No action for own messages */ }, - onLongClick = { onMessageLongPress.invoke(message) } + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onMessageLongPress.invoke(message) + } ) } else { modifier