Fix last bugs from test #5

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-07-13 01:00:47 +03:00
Unverified
parent 3c08830755
commit 09ac7035fc
3 changed files with 266 additions and 30 deletions
@@ -3,6 +3,7 @@ package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
@@ -12,6 +13,7 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
@@ -20,15 +22,19 @@ import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.rounded.KeyboardArrowDown
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
@@ -36,10 +42,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
@@ -50,10 +57,13 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.withFrameNanos
import androidx.compose.animation.core.Animatable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.zIndex
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
@@ -61,6 +71,11 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import kotlinx.datetime.LocalDate
import ru.fromchat.api.local.messages.formatChatDateSeparator
import ru.fromchat.chat_date_today
@@ -68,6 +83,7 @@ import ru.fromchat.chat_date_yesterday
import ru.fromchat.utils.rememberRegistrationDateFormatStrings
import com.pr0gramm3r101.utils.resetFocus
import com.pr0gramm3r101.utils.supportClipboardManagerImpl
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
@@ -183,15 +199,49 @@ fun ChatScreen(
val listState = rememberSaveable(panelId, saver = LazyListState.Saver) {
LazyListState(0, 0)
}
var isNearBottom by rememberSaveable(panelId) { mutableStateOf(true) }
LaunchedEffect(listState, panelState.messages.size) {
val fakeScrollOffset = remember(panelId) { Animatable(0f) }
LaunchedEffect(panelId) {
// #region agent log
agentDebugLog(
hypothesisId = "H4",
location = "ChatScreen.kt:188",
message = "chat_screen_opened",
data = mapOf(
"panelId" to panelId,
"listStateHash" to listState.hashCode(),
),
)
// #endregion
}
val isNearBottom by remember(panelId) {
derivedStateOf { listState.isChatNearBottom() }
}
LaunchedEffect(listState, panelId) {
snapshotFlow {
val minVisibleIndex = listState.layoutInfo.visibleItemsInfo.minOfOrNull { it.index }
?: Int.MAX_VALUE
minVisibleIndex <= 2
val info = listState.layoutInfo
Triple(
info.totalItemsCount,
listState.firstVisibleItemIndex,
listState.firstVisibleItemScrollOffset,
)
}
.distinctUntilChanged()
.collect { nearBottom -> isNearBottom = nearBottom }
.collect { (totalItems, firstIndex, firstOffset) ->
// #region agent log
agentDebugLog(
hypothesisId = "H1",
location = "ChatScreen.kt:196",
message = "scroll_state_changed",
data = mapOf(
"panelId" to panelId,
"totalItems" to totalItems,
"firstVisibleItemIndex" to firstIndex,
"firstVisibleItemScrollOffset" to firstOffset,
"isNearBottom" to listState.isChatNearBottom(),
),
)
// #endregion
}
}
val density = LocalDensity.current
val fallbackMessageHeightPx = remember(density) { with(density) { 80.dp.roundToPx() } }
@@ -671,8 +721,7 @@ fun ChatScreen(
previousMessageFingerprint = fingerprint
val lastIsOurs = lastMessage?.user_id == currentUserId
val minVisibleIndex = listState.layoutInfo.visibleItemsInfo.minOfOrNull { it.index } ?: Int.MAX_VALUE
val isNearBottom = minVisibleIndex <= 2
val isNearBottom = listState.isChatNearBottom()
if (lastIsOurs || isNearBottom) {
if (listState.layoutInfo.visibleItemsInfo.isNotEmpty()) {
delay(100.milliseconds)
@@ -1062,6 +1111,9 @@ fun ChatScreen(
state = listState,
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
translationY = fakeScrollOffset.value
}
.background(MaterialTheme.colorScheme.background)
.hazeSource(hazeState),
userScrollEnabled = !contextMenuState.isOpen,
@@ -1296,32 +1348,94 @@ fun ChatScreen(
item { Spacer(modifier.height(floatingHeaderClearance)) }
}
val showScrollToBottomFab = panel.usesPublicGroupSubtitle &&
!isNearBottom &&
val showScrollToBottomFab = !isNearBottom &&
panelState.messages.isNotEmpty() &&
!contextMenuState.isOpen
LaunchedEffect(showScrollToBottomFab) {
// #region agent log
agentDebugLog(
hypothesisId = "H2",
location = "ChatScreen.kt:1338",
message = "show_scroll_button_changed",
data = mapOf(
"panelId" to panelId,
"showScrollToBottomFab" to showScrollToBottomFab,
"isNearBottom" to isNearBottom,
"messageCount" to panelState.messages.size,
"contextMenuOpen" to contextMenuState.isOpen,
"usesPublicGroupSubtitle" to panel.usesPublicGroupSubtitle,
),
)
// #endregion
}
AnimatedVisibility(
visible = showScrollToBottomFab,
enter = fadeIn(tween(150)) +
expandVertically(expandFrom = Alignment.Bottom),
exit = fadeOut(tween(120)) +
shrinkVertically(shrinkTowards = Alignment.Bottom),
enter = fadeIn(
animationSpec = tween(
durationMillis = 260,
easing = FastOutSlowInEasing,
),
) + scaleIn(
initialScale = 0.7f,
animationSpec = tween(
durationMillis = 260,
easing = FastOutSlowInEasing,
),
),
exit = fadeOut(
animationSpec = tween(
durationMillis = 200,
easing = FastOutSlowInEasing,
),
) + scaleOut(
targetScale = 0.7f,
animationSpec = tween(
durationMillis = 200,
easing = FastOutSlowInEasing,
),
),
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = 16.dp),
.zIndex(2f)
.padding(
end = 13.dp,
bottom = innerPadding.calculateBottomPadding() + 12.dp,
),
) {
SmallFloatingActionButton(
ChatScrollToBottomButton(
onClick = {
scope.launch { listState.animateScrollToItem(0) }
scope.launch {
// #region agent log
agentDebugLog(
hypothesisId = "H5",
location = "ChatScreen.kt:1402",
message = "scroll_button_jump_to_bottom",
data = mapOf(
"panelId" to panelId,
"fromIndex" to listState.firstVisibleItemIndex,
"fromOffset" to listState.firstVisibleItemScrollOffset,
),
)
// #endregion
// Snap list to bottom first so content is correct.
listState.scrollChatToBottom()
// Fake a short smooth scroll by translating the whole chat
// surface from a small offset back to rest.
fakeScrollOffset.snapTo(64f)
fakeScrollOffset.animateTo(
targetValue = 0f,
animationSpec = tween(
durationMillis = 260,
easing = FastOutSlowInEasing,
),
)
}
},
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
) {
Icon(
imageVector = Icons.Default.KeyboardArrowDown,
contentDescription = scrollToBottomCd,
)
}
contentDescription = scrollToBottomCd,
hazeState = hazeState,
)
}
ChatTopBar(
@@ -1506,6 +1620,115 @@ private fun ChatDateSeparator(
}
}
private val ChatScrollToBottomButtonSize = 44.dp
private val ChatScrollToBottomIconSize = 24.dp
@Composable
private fun ChatScrollToBottomButton(
onClick: () -> Unit,
contentDescription: String,
hazeState: HazeState,
modifier: Modifier = Modifier,
) {
val hazeStyle = rememberChatSurfaceContainerHazeStyle()
Box(
modifier = modifier
.size(ChatScrollToBottomButtonSize)
.clip(CircleShape)
.clickable {
// #region agent log
agentDebugLog(
hypothesisId = "H3",
location = "ChatScreen.kt:1519",
message = "scroll_button_clicked",
data = mapOf(
"contentDescription" to contentDescription,
),
)
// #endregion
onClick()
},
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.91f))
.hazeEffect(state = hazeState, style = hazeStyle),
)
Icon(
imageVector = Icons.Rounded.KeyboardArrowDown,
contentDescription = contentDescription,
modifier = Modifier.size(ChatScrollToBottomIconSize),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
private fun agentDebugLog(
hypothesisId: String,
location: String,
message: String,
data: Map<String, Any?> = emptyMap(),
runId: String = "initial",
) {
val timestamp = kotlin.time.Clock.System.now().toEpochMilliseconds()
fun escape(value: String): String {
return value
.replace("\\", "\\\\")
.replace("\"", "\\\"")
}
val dataJson = buildString {
append("{")
data.entries.joinToString(",") { (key, rawValue) ->
val value = rawValue?.toString() ?: "null"
"\"${escape(key)}\":\"${escape(value)}\""
}.let { append(it) }
append("}")
}
val json = buildString {
append("{")
append("\"sessionId\":\"a042f7\",")
append("\"id\":\"log_")
append(timestamp)
append("_")
append(escape(hypothesisId))
append("\",")
append("\"timestamp\":")
append(timestamp)
append(",")
append("\"location\":\"")
append(escape(location))
append("\",")
append("\"message\":\"")
append(escape(message))
append("\",")
append("\"runId\":\"")
append(escape(runId))
append("\",")
append("\"hypothesisId\":\"")
append(escape(hypothesisId))
append("\",")
append("\"data\":")
append(dataJson)
append("}")
}
Logger.d("AgentDebug", json)
kotlinx.coroutines.GlobalScope.launch {
runCatching {
ApiClient.http.post("http://192.168.1.6:7629/ingest/edf4b1c4-ae73-4d46-9110-6235fc587a70") {
contentType(ContentType.Application.Json)
header("X-Debug-Session-Id", "a042f7")
setBody(json)
}
}
}
}
private suspend fun LazyListState.scrollChatMessageToCenter(
lazyIndex: Int,
topClearancePx: Int,
@@ -1,5 +1,6 @@
package ru.fromchat.ui.chat
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Immutable
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -249,6 +250,19 @@ fun buildChatListItems(
return chronological.asReversed()
}
/** Whether a reversed chat list is showing the latest messages (index 0 = bottom spacer). */
fun LazyListState.isChatNearBottom(threshold: Int = 2): Boolean {
if (layoutInfo.totalItemsCount == 0) return true
return firstVisibleItemIndex <= threshold
}
/** Scrolls a reversed chat list to the visual bottom (newest messages). */
suspend fun LazyListState.scrollChatToBottom() {
if (layoutInfo.totalItemsCount == 0) return
// Fallback: direct snap to bottom spacer; animation is handled by button enter/exit.
scrollToItem(0)
}
/** LazyColumn index for a message (index 0 = bottom spacer). */
fun lazyIndexForMessageId(listItems: List<ChatListItem>, messageId: Int): Int? {
val itemIndex = listItems.indexOfFirst {
@@ -43,4 +43,3 @@ fun DisplayName(
}
}
}