fix(#682): centralized chat scroll controller with bottom-follow + unread (#683)

Introduce ChatScrollController owning all chat scroll behavior:
- continuous bottom-follow intent tracked from LazyListState (not only on data change)
- pixel-precise bottom detection (last item edge vs viewport, small tolerance)
- streaming follow that pauses on upward scroll and never forces the reader down
- tail updates keyed on messages/streaming/thinking/subagent/clarify
- unread (new-message) count shown on the scroll-to-bottom FAB, cleared on tap
- paging anchor preserved by message id across older-history prepend
- all scroll commands (send/FAB/session switch/search/paging) serialized via one scope

Closes #682

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-22 22:26:49 +03:00
1 parent 2e97ee9a84
commit cc8f8a2773
4 files changed
+335 -155

No files matched your search

@@ -81,7 +81,8 @@ import com.m57.hermescontrol.ui.chat.components.ChatScrollToBottomFab
import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay
import com.m57.hermescontrol.ui.chat.components.ReloginDialog
import com.m57.hermescontrol.ui.chat.components.SearchBarRow
import com.m57.hermescontrol.ui.chat.components.scrollToBottom
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
import com.m57.hermescontrol.ui.chat.components.tailContentKey
import com.m57.hermescontrol.ui.common.AutoScrollingTitleText
import com.m57.hermescontrol.ui.common.CredentialWarningBanner
import com.m57.hermescontrol.ui.common.HermesScaffold
@@ -121,6 +122,8 @@ fun ChatScreen(
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
val credentialWarning by HermesWsClient.credentialWarning.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
val scrollScope = rememberCoroutineScope()
val scrollController = rememberChatScrollController(listState, scrollScope)
var isOlderPagingArmed by remember(state.currentSessionId) { mutableStateOf(false) }
// Periodic session sync while connected.
@@ -132,6 +135,10 @@ fun ChatScreen(
}
// Arm + trigger older-message paging when the user scrolls near the top.
// Before prepending, capture the anchor (first visible message id + offset)
// so the same content stays under the reader's eye after the insert
// (issue #682). Restored in a LaunchedEffect once the page actually lands.
val pagingAnchor = remember { mutableStateOf<Pair<String, Int>?>(null) }
LaunchedEffect(listState, state.currentSessionId, state.hasOlderMessages, state.isLoadingOlder) {
if (!state.hasOlderMessages || state.isLoadingOlder) return@LaunchedEffect
snapshotFlow { listState.firstVisibleItemIndex }
@@ -140,16 +147,59 @@ fun ChatScreen(
if (firstVisibleIndex > 2) {
isOlderPagingArmed = true
} else if (isOlderPagingArmed) {
val anchorId = state.messages.getOrNull(firstVisibleIndex)?.id
val offset = scrollController.captureAnchorOffset()
pagingAnchor.value = if (anchorId != null) anchorId to offset else null
viewModel.loadOlderMessages()
}
}
}
// After older history is prepended, restore the anchor message (by id, so
// streaming-token inserts during paging don't skew the index) plus offset.
LaunchedEffect(state.messages) {
val anchor = pagingAnchor.value ?: return@LaunchedEffect
val (anchorId, offset) = anchor
val index = state.messages.indexOfFirst { it.id == anchorId }
if (index >= 0) {
scrollController.scrollToItem(index, offset)
pagingAnchor.value = null
}
}
// Continuous bottom-follow tracking from LazyListState (issue #682).
LaunchedEffect(Unit) {
scrollController.observeUserScrollPosition()
}
// Drive follow + unseen tracking from a stable tail-content key covering
// messages, streaming, thinking, subagent cards, and clarify prompts
// (issue #682). Replaces the old item-count heuristic that ignored the
// streaming tail.
LaunchedEffect(
state.messages,
streamingState.streamingMessage,
streamingState.isThinking,
state.subagentIndicators,
state.clarifyRequest,
) {
scrollController.onTailChanged(
tailKey =
tailContentKey(
messages = state.messages,
streamingMessage = streamingState.streamingMessage,
isThinking = streamingState.isThinking,
subagentIndicators = state.subagentIndicators,
clarifyRequest = state.clarifyRequest,
),
messageCount = state.messages.size,
)
}
val showScrollToBottom by remember {
derivedStateOf {
state.messages.isNotEmpty() && listState.canScrollForward
scrollController.showFab(state.messages.isNotEmpty())
}
}
val scrollScope = rememberCoroutineScope()
var inputFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue(""))
}
@@ -277,8 +327,6 @@ fun ChatScreen(
connectionStatus = state.connectionStatus,
currentSessionId = state.currentSessionId,
messages = state.messages,
streamingMessage = streamingState.streamingMessage,
isThinking = streamingState.isThinking,
errorMessage = state.errorMessage,
backgroundCompleteMessage = state.backgroundCompleteMessage,
isSearchActive = state.isSearchActive,
@@ -288,6 +336,7 @@ fun ChatScreen(
sudoPrompt = state.sudoPrompt,
secretPrompt = state.secretPrompt,
listState = listState,
scrollController = scrollController,
snackbarHostState = snackbarHostState,
viewModel = viewModel,
)
@@ -446,14 +495,12 @@ fun ChatScreen(
// Loading overlay
ChatLoadingOverlay(isLoading = state.isLoading)
// Scroll-to-bottom FAB
// Scroll-to-bottom FAB (issue #682): shows while follow is
// paused and renders the unseen-message badge.
ChatScrollToBottomFab(
showScrollToBottom = showScrollToBottom,
scrollScope = scrollScope,
listState = listState,
messages = state.messages,
streamingMessage = streamingState.streamingMessage,
isThinking = streamingState.isThinking,
show = showScrollToBottom,
pendingCount = scrollController.pendingCount,
onScrollToBottom = scrollController::resumeFollowing,
)
// Reaction heartsanimation (purely cosmetic — fades out
@@ -471,15 +518,8 @@ fun ChatScreen(
onSend = {
viewModel.sendMessage(inputFieldValue.text)
inputFieldValue = TextFieldValue("")
scrollScope.launch {
val totalItems =
state.messages.size +
(if (streamingState.streamingMessage != null) 1 else 0) +
(if (streamingState.isThinking) 1 else 0)
if (totalItems > 0) {
listState.scrollToBottom(animated = true)
}
}
// Jump to bottom after send (serialized through the controller).
scrollController.jumpToBottom(animated = true)
},
onMicTap = {
if (isListening) {
@@ -11,12 +11,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
@@ -30,7 +24,6 @@ import com.m57.hermescontrol.ui.chat.ChatViewModel
import com.m57.hermescontrol.ui.chat.ClarifyUi
import com.m57.hermescontrol.ui.chat.SecretPromptUi
import com.m57.hermescontrol.ui.chat.SudoPromptUi
import kotlinx.coroutines.flow.collectLatest
@Composable
fun ChatLifecycleEffects(
@@ -38,8 +31,6 @@ fun ChatLifecycleEffects(
connectionStatus: ConnectionStatus,
currentSessionId: String?,
messages: List<ChatMessage>,
streamingMessage: ChatMessage?,
isThinking: Boolean,
errorMessage: String?,
backgroundCompleteMessage: String?,
isSearchActive: Boolean,
@@ -49,6 +40,7 @@ fun ChatLifecycleEffects(
sudoPrompt: SudoPromptUi?,
secretPrompt: SecretPromptUi?,
listState: LazyListState,
scrollController: ChatScrollController,
snackbarHostState: SnackbarHostState,
viewModel: ChatViewModel,
) {
@@ -61,19 +53,24 @@ fun ChatLifecycleEffects(
}
// Switch to session from notification/history
var lastSessionId by remember { mutableStateOf<String?>(null) }
val pendingSessionId = NavigationController.pendingSessionId
LaunchedEffect(sessionId, pendingSessionId, connectionStatus) {
LaunchedEffect(sessionId, NavigationController.pendingSessionId, connectionStatus) {
if (connectionStatus != ConnectionStatus.CONNECTED) return@LaunchedEffect
val target = if (!sessionId.isNullOrBlank()) sessionId else pendingSessionId
val target = if (!sessionId.isNullOrBlank()) sessionId else NavigationController.pendingSessionId
if (!target.isNullOrBlank()) {
viewModel.switchSession(target)
if (target == pendingSessionId) {
if (target == NavigationController.pendingSessionId) {
NavigationController.pendingSessionId = null
}
}
}
// Land instantly at the bottom on a session switch (issue #682).
LaunchedEffect(currentSessionId) {
if (currentSessionId != null) {
scrollController.jumpToBottom(animated = false)
}
}
// Lifecycle observer for notification foreground service
DisposableEffect(lifecycleOwner) {
val observer =
@@ -114,44 +111,6 @@ fun ChatLifecycleEffects(
}
}
// Auto-scroll to bottom on new messages + session switch (issues #584/#583)
//
// IMPORTANT (m57, #584 follow-up): while an assistant message is STREAMING
// the scroll is left completely FREE — no auto-follow of the streaming
// message — so the user can scroll up/down to read while it generates.
// Auto-scroll only happens on explicit actions (send / FAB / session
// switch, handled elsewhere) and when a discrete non-streaming message
// lands while the user is already pinned to the bottom.
//
// The effect runs once (Unit), so we mirror the params through
// rememberUpdatedState and read them live inside the flow/collect — that
// keeps the lambda bound to the latest values instead of first-composition
// closure captures. streamingMessage is intentionally NOT a flow key, so
// the flow doesn't churn per token; we just read it live to gate scrolling.
val latestMessages by rememberUpdatedState(messages)
val latestStreaming by rememberUpdatedState(streamingMessage)
val latestIsThinking by rememberUpdatedState(isThinking)
val latestSessionId by rememberUpdatedState(currentSessionId)
LaunchedEffect(Unit) {
snapshotFlow {
Pair(latestMessages.size, latestIsThinking)
}.collectLatest { (msgCount, thinking) ->
val totalItems = msgCount + (if (thinking) 1 else 0)
if (totalItems <= 0) return@collectLatest
// While a message is streaming, leave scrolling free (no auto-follow).
if (latestStreaming != null) return@collectLatest
val isSessionSwitch = latestSessionId != lastSessionId
if (isSessionSwitch) {
lastSessionId = latestSessionId
listState.scrollToBottom(animated = false)
return@collectLatest
}
if (!listState.isAtBottom()) return@collectLatest
// Discrete new message while pinned to the bottom — follow it.
listState.scrollToBottom(animated = true)
}
}
// Show error as snackbar
LaunchedEffect(errorMessage) {
errorMessage?.let { error ->
@@ -183,7 +142,8 @@ fun ChatLifecycleEffects(
)
}
// Scroll to current search match
// Scroll to current search match (serialized through the controller so it
// doesn't compete with auto-follow / FAB / send scrolls).
LaunchedEffect(isSearchActive, currentSearchMatchIndex, searchMatchIndices) {
if (isSearchActive &&
currentSearchMatchIndex >= 0 &&
@@ -191,9 +151,7 @@ fun ChatLifecycleEffects(
messages.isNotEmpty()
) {
val targetIndex = searchMatchIndices[currentSearchMatchIndex]
listState.animateScrollToItem(
targetIndex.coerceIn(0, messages.lastIndex),
)
scrollController.scrollToSearchMatch(targetIndex.coerceIn(0, messages.lastIndex))
}
}
}
@@ -0,0 +1,228 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
/**
* Owns all chat scroll behavior (issue #682):
*
* 1. **Bottom-follow intent** ([isFollowingBottom]) is tracked continuously from
* [LazyListState] via [snapshotFlow], not decided only when new data arrives.
* 2. **Precise bottom detection** ([isAtBottom]) checks the last item's bottom
* edge against the viewport bottom within a small pixel tolerance, instead of
* an item-count threshold.
* 3. **Streaming follow** — [onTailChanged] keeps following streamed output only
* while the reader is pinned at the bottom. An upward scroll pauses follow
* immediately (via [isFollowingBottom]) and never forces the reader back down.
* 4. **Tail updates** — callers funnel every tail change (messages, streaming,
* thinking, subagent cards, clarify prompts) through [onTailChanged] with a
* stable tail key, so follow + unread tracking react to real content changes.
* 5. **Unread indicator** — when follow is paused, [pendingCount] accumulates the
* number of tail updates; shows on the FAB. Tapping the FAB resumes following
* and clears the count.
* 6. **Paging anchor preservation** — capture the first visible item + offset
* with [captureAnchor] before prepending older history, then
* [restoreAnchor] after insertion so the same content stays under the eye.
* 7. **Serialized scroll commands** — every scroll (send, FAB, session switch,
* search navigation, auto-follow) is launched from [scope] so animations
* don't compete.
*
* Construct with [rememberChatScrollController].
*/
class ChatScrollController(
private val listState: LazyListState,
internal val scope: CoroutineScope,
) {
/** True while the reader is pinned at (or within tolerance of) the bottom. */
var isFollowingBottom by mutableStateOf(true)
private set
/**
* Number of new messages that arrived while follow was paused. Shown on the
* scroll-to-bottom FAB; cleared when the reader resumes following.
*/
var pendingCount by mutableStateOf(0)
private set
private var lastSessionId: String? = null
private var lastTailKey: Any? = null
private var lastMessageCount: Int = 0
/**
* Pixel tolerance for "at bottom" detection. Kept small so a reader a few
* px above the fold is still considered not-at-bottom (no accidental pulls).
*/
var bottomPixelTolerance by mutableStateOf(8)
/** Serialized scroll scope — all scroll jobs funnel through here. */
fun launchScroll(block: suspend CoroutineScope.() -> Unit) {
scope.launch(block = block)
}
/**
* Read the live bottom-follow state from [LazyListState] and react to
* user-driven scroll position changes. Should be called inside a
* `LaunchedEffect(Unit)` so it observes the list for the screen's lifetime.
*/
fun observeUserScrollPosition() {
scope.launch {
snapshotFlow { listState.isAtBottom(bottomPixelTolerance) }
.distinctUntilChanged()
.collect { atBottom ->
if (atBottom) {
isFollowingBottom = true
// Reaching the bottom means the reader caught up.
pendingCount = 0
} else {
isFollowingBottom = false
}
}
}
}
/**
* Call on every tail-content change (new message, streaming token, thinking
* toggle, subagent card, clarify prompt). [tailKey] must be a stable value
* that changes only when the tail content actually changes — pass
* [tailContentKey] for a ready-made key. [messageCount] is the current total
* message count, used to derive the unseen-message badge while paused.
*
* Follows the tail only when [isFollowingBottom] was true before this
* change. While paused, increments [pendingCount] by the number of new
* messages instead.
*/
fun onTailChanged(
tailKey: Any?,
messageCount: Int = 0,
) {
if (tailKey == lastTailKey) {
lastMessageCount = messageCount
return
}
val newMessages = (messageCount - lastMessageCount).coerceAtLeast(0)
lastMessageCount = messageCount
lastTailKey = tailKey
if (isFollowingBottom) {
// Pin instantly so rapid streamed tokens don't queue competing
// animations; the reader stays glued to the growing tail.
scope.launch { listState.scrollToBottom(animated = false) }
} else {
pendingCount += newMessages
}
}
/** Force-follow to the bottom (session switch / explicit send). Clears unread. */
fun jumpToBottom(animated: Boolean = false) {
pendingCount = 0
isFollowingBottom = true
scope.launch { listState.scrollToBottom(animated = animated) }
}
/** FAB tap: resume following + clear unread. */
fun resumeFollowing() = jumpToBottom(animated = true)
/** True when the FAB should be visible (not following bottom + content exists). */
fun showFab(contentPresent: Boolean): Boolean = !isFollowingBottom && contentPresent
/**
* Current pixel scroll offset of the first visible item — captured before
* prepending older history so it can be restored after insertion.
*/
fun captureAnchorOffset(): Int = listState.firstVisibleItemScrollOffset
/**
* Restore the viewport to a specific item (resolved by id in the caller, so
* the restore is robust even if streaming tokens arrive during the page
* insert). Keeps the reader's eye on the same content after prepend.
*/
fun scrollToItem(
index: Int,
offset: Int,
) {
scope.launch { listState.scrollToItem(index, offset) }
}
/** Navigate to a search match index (serialized through [scope]). */
fun scrollToSearchMatch(index: Int) {
scope.launch { listState.animateScrollToItem(index) }
}
}
/**
* Stable tail-key covering messages, streaming state, typing, subagent cards,
* and clarification prompts — anything that can change the list tail.
*/
fun tailContentKey(
messages: List<*>,
streamingMessage: Any?,
isThinking: Boolean,
subagentIndicators: List<*>,
clarifyRequest: Any?,
): Any =
listOf(
messages.size,
streamingMessage?.hashCode() ?: 0,
isThinking,
subagentIndicators.size,
clarifyRequest?.hashCode() ?: 0,
)
/**
* Continuous bottom-follow tracker derived from [LazyListState]: true when the
* last item's bottom edge is at (or within [tolerance] px of) the viewport
* bottom. Replaces the old item-count `isAtBottom(threshold)` heuristic.
*/
fun LazyListState.isAtBottom(tolerance: Int = 8): Boolean {
val layoutInfo = this.layoutInfo
val visibleItems = layoutInfo.visibleItemsInfo
if (visibleItems.isEmpty()) return true
val lastItem = visibleItems.last()
val lastBottom = lastItem.offset + lastItem.size
val viewportBottom = layoutInfo.viewportEndOffset - layoutInfo.afterContentPadding
return lastBottom >= viewportBottom - tolerance
}
/**
* Scroll so the bottom edge of the last item is aligned to the viewport bottom.
* Top-aligns first (instant or animated), then scrolls the exact remaining gap
* so a taller-than-viewport last item's bottom stays visible. Uses the remaining
* delta (not `Int.MAX_VALUE`) to avoid integer-overflow wrap in the internal
* scroll-position clamp.
*/
suspend fun LazyListState.scrollToBottom(animated: Boolean) {
val layoutInfo = this.layoutInfo
if (layoutInfo.totalItemsCount == 0) return
val lastIndex = layoutInfo.totalItemsCount - 1
if (animated) {
animateScrollToItem(lastIndex)
} else {
scrollToItem(lastIndex)
}
val info = this.layoutInfo
val lastItem = info.visibleItemsInfo.lastOrNull { it.index == lastIndex } ?: return
val remaining =
(lastItem.offset + lastItem.size + info.afterContentPadding) - info.viewportEndOffset
if (remaining > 0) {
if (animated) {
animateScrollBy(remaining.toFloat())
} else {
scroll { scrollBy(remaining.toFloat()) }
}
}
}
@Composable
fun rememberChatScrollController(
listState: LazyListState,
scope: CoroutineScope,
): ChatScrollController = remember(listState, scope) { ChatScrollController(listState, scope) }
@@ -5,90 +5,43 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.chat.ChatMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
/**
* Returns true when the list is scrolled near its bottom
* (within [threshold] items of the last index).
*/
fun LazyListState.isAtBottom(threshold: Int = 3): Boolean {
val layoutInfo = this.layoutInfo
val visibleItems = layoutInfo.visibleItemsInfo
if (visibleItems.isEmpty()) return true
val lastVisibleItem = visibleItems.last()
return lastVisibleItem.index >= layoutInfo.totalItemsCount - threshold
}
/**
* Scroll so the bottom edge of the last item is aligned to the bottom of the
* viewport (issue #583).
* Floating action button shown when the message list is not following the
* bottom (issue #682). Tapping it resumes bottom-follow and clears the pending
* (unseen) update count.
*
* `animateScrollToItem(lastIndex)` only top-aligns the last item, so when the
* last item is taller than the viewport its bottom is clipped below the fold.
* We first top-align (instant), then scroll the exact remaining gap so the
* bottom is visible. Using the remaining delta (not `scrollOffset = Int.MAX_VALUE`)
* avoids integer overflow in the internal scroll-position clamp, which would
* otherwise wrap the offset to 0 and scroll back to the top.
*/
suspend fun LazyListState.scrollToBottom(animated: Boolean) {
val layoutInfo = this.layoutInfo
if (layoutInfo.totalItemsCount == 0) return
val lastIndex = layoutInfo.totalItemsCount - 1
// Top-align the last item first so layoutInfo reflects the last item's offset.
// When animated, use the animated variant so the FAB / send clicks keep a
// smooth scroll instead of an instant jump followed by a tiny animation.
if (animated) {
animateScrollToItem(lastIndex)
} else {
scrollToItem(lastIndex)
}
val info = this.layoutInfo
val lastItem = info.visibleItemsInfo.lastOrNull { it.index == lastIndex } ?: return
val remaining =
(lastItem.offset + lastItem.size + info.afterContentPadding) - info.viewportEndOffset
if (remaining > 0) {
if (animated) {
animateScrollBy(remaining.toFloat())
} else {
scroll { scrollBy(remaining.toFloat()) }
}
}
}
/**
* Floating action button shown when the message list is not at the bottom;
* tapping scrolls the list back to the bottom.
* @param show when the reader is NOT at the bottom and there is content.
* @param pendingCount number of tail updates that arrived while follow was
* paused — rendered as a badge on the FAB.
* @param onScrollToBottom invoked on tap; should call
* [ChatScrollController.resumeFollowing].
*/
@Composable
fun BoxScope.ChatScrollToBottomFab(
showScrollToBottom: Boolean,
scrollScope: CoroutineScope,
listState: LazyListState,
messages: List<ChatMessage>,
streamingMessage: ChatMessage?,
isThinking: Boolean,
show: Boolean,
pendingCount: Int,
onScrollToBottom: () -> Unit,
) {
AnimatedVisibility(
visible = showScrollToBottom,
visible = show,
enter = fadeIn() + scaleIn(),
exit = fadeOut() + scaleOut(),
modifier =
@@ -96,26 +49,27 @@ fun BoxScope.ChatScrollToBottomFab(
.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = 16.dp),
) {
FloatingActionButton(
onClick = {
scrollScope.launch {
val totalItems =
messages.size +
(if (streamingMessage != null) 1 else 0) +
(if (isThinking) 1 else 0)
if (totalItems > 0) {
listState.scrollToBottom(animated = true)
BadgedBox(
badge = {
if (pendingCount > 0) {
Badge {
val label = if (pendingCount > 99) "99+" else pendingCount.toString()
Text(label)
}
}
},
modifier = Modifier.size(40.dp),
containerColor = MaterialTheme.colorScheme.surfaceContainer,
contentColor = MaterialTheme.colorScheme.onSurface,
) {
Icon(
imageVector = Icons.Filled.KeyboardArrowDown,
contentDescription = stringResource(R.string.content_desc_scroll_to_bottom),
)
FloatingActionButton(
onClick = onScrollToBottom,
modifier = Modifier.size(40.dp),
containerColor = MaterialTheme.colorScheme.surfaceContainer,
contentColor = MaterialTheme.colorScheme.onSurface,
) {
Icon(
imageVector = Icons.Filled.KeyboardArrowDown,
contentDescription = stringResource(R.string.content_desc_scroll_to_bottom),
)
}
}
}
}