From 1d3c1edfeafbe750dd000bfb1dafe9a364962daa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 15 May 2026 21:29:02 +0000 Subject: [PATCH 1/2] fix(feeds): offset PullToRefresh indicator below top bar Inside a DisappearingScaffold the content fills the full screen height so feeds can scroll behind the top bar. PullToRefreshBox anchors its indicator at the top of the box, which placed it behind the top bar. Read LocalDisappearingScaffoldPadding inside RefresheableBox and pass a custom indicator with that top padding applied, so the spinner surfaces just below the bar. Screens not inside a DisappearingScaffold see the default 0dp padding and behave as before. https://claude.ai/code/session_01MgnMaw8U83rXPSnoGF4KBY --- .../amethyst/ui/feeds/RefresheableBox.kt | 47 +++++++++++++++++-- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RefresheableBox.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RefresheableBox.kt index 3c18d5c9c6..82d4f26250 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RefresheableBox.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RefresheableBox.kt @@ -23,15 +23,20 @@ package com.vitorpamplona.amethyst.ui.feeds import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.ui.layouts.LocalDisappearingScaffoldPadding import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -54,10 +59,9 @@ fun RefresheableBox( } if (enablePullRefresh) { - PullToRefreshBox( + PullToRefreshBoxWithScaffoldPadding( isRefreshing = isRefreshing, onRefresh = onRefresh, - modifier = Modifier.fillMaxSize(), content = content, ) } else { @@ -82,10 +86,45 @@ fun RefresheableBox( } } - PullToRefreshBox( + PullToRefreshBoxWithScaffoldPadding( isRefreshing = isRefreshing, onRefresh = onRefreshWrapped, - modifier = Modifier.fillMaxSize(), + content = content, + ) +} + +/** + * [PullToRefreshBox] anchors its indicator at the top of the box. Inside a + * [DisappearingScaffold] the content fills the full screen height (feed items + * scroll behind the top bar), so the default indicator would appear behind the + * top bar. Offset the indicator down by the scaffold's top padding so it + * surfaces just below the bar. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PullToRefreshBoxWithScaffoldPadding( + isRefreshing: Boolean, + onRefresh: () -> Unit, + content: @Composable BoxScope.() -> Unit, +) { + val state = rememberPullToRefreshState() + val topPadding = LocalDisappearingScaffoldPadding.current.calculateTopPadding() + + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = onRefresh, + state = state, + modifier = Modifier.fillMaxSize(), + indicator = { + PullToRefreshDefaults.Indicator( + modifier = + Modifier + .align(Alignment.TopCenter) + .padding(top = topPadding), + isRefreshing = isRefreshing, + state = state, + ) + }, content = content, ) } From b9f98a4f5b931ba6b46647adb783f097b2e6a143 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 15 May 2026 21:40:34 +0000 Subject: [PATCH 2/2] refactor(feeds): collapse RefresheableBox overloads to a single PullToRefreshBox The InvalidatableContent overload now delegates to the onRefresh overload, so there is only one PullToRefreshBox/state-management path to maintain. https://claude.ai/code/session_01MgnMaw8U83rXPSnoGF4KBY --- .../amethyst/ui/feeds/RefresheableBox.kt | 61 ++++--------------- 1 file changed, 13 insertions(+), 48 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RefresheableBox.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RefresheableBox.kt index 82d4f26250..bd38e9d4b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RefresheableBox.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RefresheableBox.kt @@ -40,30 +40,14 @@ import com.vitorpamplona.amethyst.ui.layouts.LocalDisappearingScaffoldPadding import kotlinx.coroutines.delay import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterial3Api::class) @Composable fun RefresheableBox( invalidateableContent: InvalidatableContent, enablePullRefresh: Boolean = true, content: @Composable BoxScope.() -> Unit, ) { - var isRefreshing by remember { mutableStateOf(false) } - val scope = rememberCoroutineScope() - val onRefresh: () -> Unit = { - isRefreshing = true - scope.launch { - invalidateableContent.invalidateData() - delay(500) - isRefreshing = false - } - } - if (enablePullRefresh) { - PullToRefreshBoxWithScaffoldPadding( - isRefreshing = isRefreshing, - onRefresh = onRefresh, - content = content, - ) + RefresheableBox(onRefresh = invalidateableContent::invalidateData, content = content) } else { Box(Modifier.fillMaxSize(), content = content) } @@ -77,44 +61,25 @@ fun RefresheableBox( ) { var isRefreshing by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() - val onRefreshWrapped: () -> Unit = { - isRefreshing = true - scope.launch { - onRefresh() - delay(500) - isRefreshing = false - } - } - - PullToRefreshBoxWithScaffoldPadding( - isRefreshing = isRefreshing, - onRefresh = onRefreshWrapped, - content = content, - ) -} - -/** - * [PullToRefreshBox] anchors its indicator at the top of the box. Inside a - * [DisappearingScaffold] the content fills the full screen height (feed items - * scroll behind the top bar), so the default indicator would appear behind the - * top bar. Offset the indicator down by the scaffold's top padding so it - * surfaces just below the bar. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun PullToRefreshBoxWithScaffoldPadding( - isRefreshing: Boolean, - onRefresh: () -> Unit, - content: @Composable BoxScope.() -> Unit, -) { val state = rememberPullToRefreshState() val topPadding = LocalDisappearingScaffoldPadding.current.calculateTopPadding() PullToRefreshBox( isRefreshing = isRefreshing, - onRefresh = onRefresh, + onRefresh = { + isRefreshing = true + scope.launch { + onRefresh() + delay(500) + isRefreshing = false + } + }, state = state, modifier = Modifier.fillMaxSize(), + // Anchor the indicator below the scaffold's top bar. Inside a DisappearingScaffold + // the content fills the full screen height (feed items scroll behind the bar), + // so the default top-aligned indicator would sit behind the top bar. Outside a + // scaffold the local default is 0.dp and behaviour is unchanged. indicator = { PullToRefreshDefaults.Indicator( modifier =