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.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding 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.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut 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.animation.core.tween
import androidx.compose.material.icons.Icons 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.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -36,10 +42,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
@@ -50,10 +57,13 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.withFrameNanos import androidx.compose.runtime.withFrameNanos
import androidx.compose.animation.core.Animatable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.geometry.Rect
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity 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.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp 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 kotlinx.datetime.LocalDate
import ru.fromchat.api.local.messages.formatChatDateSeparator import ru.fromchat.api.local.messages.formatChatDateSeparator
import ru.fromchat.chat_date_today import ru.fromchat.chat_date_today
@@ -68,6 +83,7 @@ import ru.fromchat.chat_date_yesterday
import ru.fromchat.utils.rememberRegistrationDateFormatStrings import ru.fromchat.utils.rememberRegistrationDateFormatStrings
import com.pr0gramm3r101.utils.resetFocus import com.pr0gramm3r101.utils.resetFocus
import com.pr0gramm3r101.utils.supportClipboardManagerImpl import com.pr0gramm3r101.utils.supportClipboardManagerImpl
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeProgressive import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
@@ -183,15 +199,49 @@ fun ChatScreen(
val listState = rememberSaveable(panelId, saver = LazyListState.Saver) { val listState = rememberSaveable(panelId, saver = LazyListState.Saver) {
LazyListState(0, 0) LazyListState(0, 0)
} }
var isNearBottom by rememberSaveable(panelId) { mutableStateOf(true) } val fakeScrollOffset = remember(panelId) { Animatable(0f) }
LaunchedEffect(listState, panelState.messages.size) { 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 { snapshotFlow {
val minVisibleIndex = listState.layoutInfo.visibleItemsInfo.minOfOrNull { it.index } val info = listState.layoutInfo
?: Int.MAX_VALUE Triple(
minVisibleIndex <= 2 info.totalItemsCount,
listState.firstVisibleItemIndex,
listState.firstVisibleItemScrollOffset,
)
} }
.distinctUntilChanged() .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 density = LocalDensity.current
val fallbackMessageHeightPx = remember(density) { with(density) { 80.dp.roundToPx() } } val fallbackMessageHeightPx = remember(density) { with(density) { 80.dp.roundToPx() } }
@@ -671,8 +721,7 @@ fun ChatScreen(
previousMessageFingerprint = fingerprint previousMessageFingerprint = fingerprint
val lastIsOurs = lastMessage?.user_id == currentUserId val lastIsOurs = lastMessage?.user_id == currentUserId
val minVisibleIndex = listState.layoutInfo.visibleItemsInfo.minOfOrNull { it.index } ?: Int.MAX_VALUE val isNearBottom = listState.isChatNearBottom()
val isNearBottom = minVisibleIndex <= 2
if (lastIsOurs || isNearBottom) { if (lastIsOurs || isNearBottom) {
if (listState.layoutInfo.visibleItemsInfo.isNotEmpty()) { if (listState.layoutInfo.visibleItemsInfo.isNotEmpty()) {
delay(100.milliseconds) delay(100.milliseconds)
@@ -1062,6 +1111,9 @@ fun ChatScreen(
state = listState, state = listState,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.graphicsLayer {
translationY = fakeScrollOffset.value
}
.background(MaterialTheme.colorScheme.background) .background(MaterialTheme.colorScheme.background)
.hazeSource(hazeState), .hazeSource(hazeState),
userScrollEnabled = !contextMenuState.isOpen, userScrollEnabled = !contextMenuState.isOpen,
@@ -1296,32 +1348,94 @@ fun ChatScreen(
item { Spacer(modifier.height(floatingHeaderClearance)) } item { Spacer(modifier.height(floatingHeaderClearance)) }
} }
val showScrollToBottomFab = panel.usesPublicGroupSubtitle && val showScrollToBottomFab = !isNearBottom &&
!isNearBottom &&
panelState.messages.isNotEmpty() && panelState.messages.isNotEmpty() &&
!contextMenuState.isOpen !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( AnimatedVisibility(
visible = showScrollToBottomFab, visible = showScrollToBottomFab,
enter = fadeIn(tween(150)) + enter = fadeIn(
expandVertically(expandFrom = Alignment.Bottom), animationSpec = tween(
exit = fadeOut(tween(120)) + durationMillis = 260,
shrinkVertically(shrinkTowards = Alignment.Bottom), 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 modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = 16.dp), .zIndex(2f)
.padding(
end = 13.dp,
bottom = innerPadding.calculateBottomPadding() + 12.dp,
),
) { ) {
SmallFloatingActionButton( ChatScrollToBottomButton(
onClick = { 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, contentDescription = scrollToBottomCd,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer, hazeState = hazeState,
) { )
Icon(
imageVector = Icons.Default.KeyboardArrowDown,
contentDescription = scrollToBottomCd,
)
}
} }
ChatTopBar( 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( private suspend fun LazyListState.scrollChatMessageToCenter(
lazyIndex: Int, lazyIndex: Int,
topClearancePx: Int, topClearancePx: Int,
@@ -1,5 +1,6 @@
package ru.fromchat.ui.chat package ru.fromchat.ui.chat
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -249,6 +250,19 @@ fun buildChatListItems(
return chronological.asReversed() 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). */ /** LazyColumn index for a message (index 0 = bottom spacer). */
fun lazyIndexForMessageId(listItems: List<ChatListItem>, messageId: Int): Int? { fun lazyIndexForMessageId(listItems: List<ChatListItem>, messageId: Int): Int? {
val itemIndex = listItems.indexOfFirst { val itemIndex = listItems.indexOfFirst {
@@ -42,5 +42,4 @@ fun DisplayName(
) )
} }
} }
} }