diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/DeletedItemsBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/DeletedItemsBanner.kt index 13c3e23dc1..e618bdbaf5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/DeletedItemsBanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/DeletedItemsBanner.kt @@ -44,10 +44,11 @@ fun DeletedItemsBanner( count: Int, onRemove: () -> Unit, onDismiss: () -> Unit, + modifier: Modifier = Modifier, ) { if (count <= 0) return - Column(modifier = StdPadding) { + Column(modifier = modifier.then(StdPadding)) { Card( modifier = MaterialTheme.colorScheme.imageModifier, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt index e55d48f38c..1fe04f30f3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt @@ -32,6 +32,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -50,7 +51,7 @@ fun FeedLoaded( val items by loaded.feed.collectAsStateWithLifecycle() LazyColumn( - contentPadding = FeedPadding, + contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, ) { itemsIndexed( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScroll.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScroll.kt new file mode 100644 index 0000000000..acab585c4a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScroll.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.layouts + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.unit.Velocity + +/** + * Scroll-linked connection that hides/reveals the top and bottom bars together. + * + * Philosophy: the bars never consume scroll input. They simply ride along with the + * content — their offset changes at the same rate as the scroll, so the user keeps full + * control of the list with their finger. This mirrors the behaviour of Twitter, Instagram, + * Bluesky, etc., where content scrolling is never delayed by the chrome. + * + * - onPostScroll reads `consumed.y + available.y` (the total scroll attempt that entered + * the nested-scroll chain) and updates the bar offsets. Using the sum means the bars + * also respond to overscroll attempts at the list edges. + * - onPostFling snaps a mid-way bar to the nearest edge, using the fling's remaining + * velocity as the spring's initial velocity so the settle feels continuous. No velocity + * is returned upward to avoid phantom scrolls on parent containers. + */ +class DisappearingBarNestedScroll( + private val state: DisappearingBarState, + private val canScroll: () -> Boolean, + private val reverseLayout: Boolean, +) : NestedScrollConnection { + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + if (!canScroll()) return Offset.Zero + val totalY = consumed.y + available.y + if (totalY == 0f) return Offset.Zero + + val deltaY = if (reverseLayout) -totalY else totalY + applyDelta(deltaY) + // Never consume: the content scrolls freely while the bars slide along. + return Offset.Zero + } + + override suspend fun onPostFling( + consumed: Velocity, + available: Velocity, + ): Velocity { + if (canScroll()) { + val velocityY = if (reverseLayout) -available.y else available.y + state.settleToNearestEdge(initialVelocityY = velocityY) + } + return Velocity.Zero + } + + private fun applyDelta(deltaY: Float) { + val topLimit = state.topHeightLimit + val bottomLimit = state.bottomHeightLimit + state.topHeightOffset = (state.topHeightOffset + deltaY).coerceIn(-topLimit, 0f) + state.bottomHeightOffset = (state.bottomHeightOffset + deltaY).coerceIn(-bottomLimit, 0f) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarState.kt new file mode 100644 index 0000000000..d426b09e7d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarState.kt @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.layouts + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +/** + * Shared state for the disappearing top / bottom bar chrome. + * + * Both offsets are negative-or-zero. 0 = fully visible; -limit = fully hidden. + * + * Limits are updated by the layout pass once it measures the bar slots. The nested-scroll + * connection reads both limits and offsets to clamp movement to the visible travel range. + */ +@Stable +class DisappearingBarState( + initialTopHeightOffset: Float = 0f, + initialBottomHeightOffset: Float = 0f, +) { + var topHeightOffset by mutableFloatStateOf(initialTopHeightOffset) + var bottomHeightOffset by mutableFloatStateOf(initialBottomHeightOffset) + + var topHeightLimit: Float = 0f + set(value) { + field = value + if (topHeightOffset < -value) topHeightOffset = -value + } + + var bottomHeightLimit: Float = 0f + set(value) { + field = value + if (bottomHeightOffset < -value) bottomHeightOffset = -value + } + + val topCollapsedFraction: Float + get() = if (topHeightLimit <= 0f) 0f else (-topHeightOffset / topHeightLimit).coerceIn(0f, 1f) + + val bottomCollapsedFraction: Float + get() = if (bottomHeightLimit <= 0f) 0f else (-bottomHeightOffset / bottomHeightLimit).coerceIn(0f, 1f) + + /** + * Snaps both bars to the nearest edge (fully shown or fully hidden). + * + * If [initialVelocityY] is non-zero the spring continues the fling's motion rather than + * starting from rest, avoiding the "extra animation at the end of the fling" feel. The + * velocity is in content-space (negative = hide direction, positive = reveal direction). + */ + suspend fun settleToNearestEdge(initialVelocityY: Float = 0f) { + coroutineScope { + launch { settleOne({ topHeightOffset }, topHeightLimit, initialVelocityY) { topHeightOffset = it } } + launch { settleOne({ bottomHeightOffset }, bottomHeightLimit, initialVelocityY) { bottomHeightOffset = it } } + } + } + + /** + * Animates both bars back to the fully visible resting state. Used on lifecycle resume. + */ + suspend fun resetToVisible() { + coroutineScope { + launch { animateOne({ topHeightOffset }, 0f, 0f) { topHeightOffset = it } } + launch { animateOne({ bottomHeightOffset }, 0f, 0f) { bottomHeightOffset = it } } + } + } + + private suspend fun settleOne( + get: () -> Float, + limit: Float, + initialVelocityY: Float, + set: (Float) -> Unit, + ) { + if (limit <= 0f) return + val current = get() + if (current >= 0f || current <= -limit) return + + // Decide target edge from position by default, but let a strong velocity bias it. + val positionBiasToHide = -current > limit / 2f + val target = + when { + initialVelocityY < -VELOCITY_BIAS_THRESHOLD -> -limit + initialVelocityY > VELOCITY_BIAS_THRESHOLD -> 0f + positionBiasToHide -> -limit + else -> 0f + } + animateOne(get, target, initialVelocityY, set) + } + + private suspend fun animateOne( + get: () -> Float, + target: Float, + initialVelocity: Float, + set: (Float) -> Unit, + ) { + val start = get() + if (start == target && initialVelocity == 0f) return + Animatable(start) + .animateTo( + targetValue = target, + animationSpec = SETTLE_SPRING, + initialVelocity = initialVelocity, + ) { + set(value) + } + } + + companion object { + private const val VELOCITY_BIAS_THRESHOLD = 200f + + private val SETTLE_SPRING = + spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ) + + val Saver: Saver = + Saver( + save = { listOf(it.topHeightOffset, it.bottomHeightOffset) }, + restore = { DisappearingBarState(it[0], it[1]) }, + ) + } +} + +@Composable +fun rememberDisappearingBarState(): DisappearingBarState = rememberSaveable(saver = DisappearingBarState.Saver) { DisappearingBarState() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBottomBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBottomBar.kt deleted file mode 100644 index 68281cf55d..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBottomBar.kt +++ /dev/null @@ -1,171 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.layouts - -import androidx.compose.animation.core.AnimationSpec -import androidx.compose.animation.core.AnimationState -import androidx.compose.animation.core.DecayAnimationSpec -import androidx.compose.animation.core.animateDecay -import androidx.compose.animation.core.animateTo -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.draggable -import androidx.compose.foundation.gestures.rememberDraggableState -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.BottomAppBarScrollBehavior -import androidx.compose.material3.BottomAppBarState -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.layout -import androidx.compose.ui.unit.Velocity -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner -import kotlinx.coroutines.launch -import kotlin.math.abs -import kotlin.math.roundToInt - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun DisappearingBottomBar( - scrollBehavior: BottomAppBarScrollBehavior, - content: @Composable (ColumnScope.() -> Unit), -) { - // Set up support for resizing the bottom app bar when vertically dragging the bar itself. - val appBarDragModifier = - if (!scrollBehavior.isPinned) { - Modifier.draggable( - orientation = Orientation.Vertical, - state = rememberDraggableState { delta -> scrollBehavior.state.heightOffset -= delta }, - onDragStopped = { velocity -> - settleAppBarBottom( - scrollBehavior.state, - velocity, - scrollBehavior.flingAnimationSpec, - scrollBehavior.snapAnimationSpec, - ) - }, - ) - } else { - Modifier - } - - ResetDisappearingOnResume(scrollBehavior) - - Column( - modifier = - Modifier - .fillMaxWidth() - .layout { measurable, constraints -> - val placeable = measurable.measure(constraints) - - scrollBehavior.state.heightOffsetLimit = -placeable.height.toFloat() - val height = placeable.height + scrollBehavior.state.heightOffset - layout(placeable.width, height.roundToInt().coerceAtLeast(0)) { placeable.place(0, 0) } - }.then(appBarDragModifier), - content = content, - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun ResetDisappearingOnResume(scrollBehavior: BottomAppBarScrollBehavior) { - // resets bar on resume - val lifeCycleOwner = LocalLifecycleOwner.current - val scope = rememberCoroutineScope() - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME && scrollBehavior.state.heightOffset != 0f) { - val spec = scrollBehavior.snapAnimationSpec - if (spec != null) { - scope.launch { - AnimationState(initialValue = scrollBehavior.state.heightOffset) - .animateTo(0f, animationSpec = spec) { - scrollBehavior.state.heightOffset = value - } - } - } else { - scrollBehavior.state.heightOffset = 0f - } - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -private suspend fun settleAppBarBottom( - state: BottomAppBarState, - velocity: Float, - flingAnimationSpec: DecayAnimationSpec?, - snapAnimationSpec: AnimationSpec?, -): Velocity { - // Check if the app bar is completely collapsed/expanded. If so, no need to settle the app bar, - // and just return Zero Velocity. - // Note that we don't check for 0f due to float precision with the collapsedFraction - // calculation. - if (state.collapsedFraction < 0.01f || state.collapsedFraction == 1f) { - return Velocity.Zero - } - var remainingVelocity = velocity - // In case there is an initial velocity that was left after a previous user fling, animate to - // continue the motion to expand or collapse the app bar. - if (flingAnimationSpec != null && abs(velocity) > 1f) { - var lastValue = 0f - AnimationState( - initialValue = 0f, - initialVelocity = velocity, - ).animateDecay(flingAnimationSpec) { - val delta = value - lastValue - val initialHeightOffset = state.heightOffset - state.heightOffset = initialHeightOffset + delta - val consumed = abs(initialHeightOffset - state.heightOffset) - lastValue = value - remainingVelocity = this.velocity - // avoid rounding errors and stop if anything is unconsumed - if (abs(delta - consumed) > 0.5f) this.cancelAnimation() - } - } - // Snap if animation specs were provided. - if (snapAnimationSpec != null) { - if (state.heightOffset < 0 && state.heightOffset > state.heightOffsetLimit) { - AnimationState(initialValue = state.heightOffset).animateTo( - if (state.collapsedFraction < 0.5f) { - 0f - } else { - state.heightOffsetLimit - }, - animationSpec = snapAnimationSpec, - ) { - state.heightOffset = value - } - } - } - - return Velocity(0f, remainingVelocity) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingFloatingButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingFloatingButton.kt deleted file mode 100644 index e8f9d1d24b..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingFloatingButton.kt +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.layouts - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.material3.BottomAppBarScrollBehavior -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.layout - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun DisappearingFloatingButton( - scrollBehavior: BottomAppBarScrollBehavior, - content: @Composable (BoxScope.() -> Unit), -) { - // We calculate the scale/alpha based on how much the bar is expanded - // 1.0 = fully visible, 0.0 = fully hidden - val progress = (1f - scrollBehavior.state.collapsedFraction).coerceAtLeast(0.001f) - - Box( - modifier = - Modifier - .layout { measurable, constraints -> - val placeable = measurable.measure(constraints) - // Adjust the height of the layout so the FAB doesn't leave a "hole" - val currentHeight = (placeable.height * progress).toInt() - layout(placeable.width, currentHeight) { - placeable.placeRelative(0, 0) - } - }.graphicsLayer { - this.scaleX = progress - this.scaleY = progress - this.alpha = progress - clip = false - }, - contentAlignment = Alignment.TopCenter, - content = content, - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt index 3096fb99a6..29253e02b2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt @@ -20,20 +20,37 @@ */ package com.vitorpamplona.amethyst.ui.layouts +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.imePadding -import androidx.compose.material3.BottomAppBarDefaults -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Scaffold +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import kotlinx.coroutines.launch + +private enum class DisappearingSlot { Top, Bottom, Content, Fab } + +private val FabEdgePadding = 16.dp -@OptIn(ExperimentalMaterial3Api::class) @Composable fun DisappearingScaffold( isInvertedLayout: Boolean, @@ -42,52 +59,182 @@ fun DisappearingScaffold( floatingButton: (@Composable () -> Unit)? = null, accountViewModel: AccountViewModel, isActive: () -> Boolean = { true }, + allowBarHide: Boolean = true, mainContent: @Composable (padding: PaddingValues) -> Unit, ) { - val topBehavior = - enterAlwaysScrollBehavior( - canScroll = { - topBar != null && isActive() && accountViewModel.settings.isImmersiveScrollingActive() - }, - reverseLayout = isInvertedLayout, - ) - val bottomBehavior = - BottomAppBarDefaults.exitAlwaysScrollBehavior( - canScroll = { - (bottomBar != null || floatingButton != null) && isActive() && accountViewModel.settings.isImmersiveScrollingActive() - }, - ) + val state = rememberDisappearingBarState() - Scaffold( - modifier = - Modifier - .imePadding() - .nestedScroll(topBehavior.nestedScrollConnection) - .nestedScroll(bottomBehavior.nestedScrollConnection), - bottomBar = { - bottomBar?.let { - DisappearingBottomBar(bottomBehavior) { - it() - } - } - }, - topBar = { - topBar?.let { - DisappearingTopBar(topBehavior) { - Column { - it() + // Hold the latest values in state so the NSC's captured lambda stays fresh across + // recompositions without rebuilding the NSC itself. + val latestIsActive by rememberUpdatedState(isActive) + val latestAllowBarHide by rememberUpdatedState(allowBarHide) + val latestAccountViewModel by rememberUpdatedState(accountViewModel) + + val connection = + remember(state, isInvertedLayout) { + DisappearingBarNestedScroll( + state = state, + canScroll = { + latestAllowBarHide && + latestIsActive() && + latestAccountViewModel.settings.isImmersiveScrollingActive() + }, + reverseLayout = isInvertedLayout, + ) + } + + // Only wire the lifecycle observer when the scaffold actually moves its bars. + if (allowBarHide) ResetBarsOnResume(state) + + // When bars are pinned, skip attaching the nested-scroll connection entirely. + // The outer Surface provides the Material container color + onBackground as + // LocalContentColor, matching M3 Scaffold's behaviour (without it, default text + // color falls back to Color.Black and is invisible on the dark theme). + val rootModifier = + if (allowBarHide) { + Modifier.imePadding().nestedScroll(connection) + } else { + Modifier.imePadding() + } + + Surface( + modifier = rootModifier, + color = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, + ) { + ScaffoldLayout( + state = state, + topBar = topBar, + bottomBar = bottomBar, + floatingButton = floatingButton, + mainContent = mainContent, + ) + } +} + +@Composable +private fun ScaffoldLayout( + state: DisappearingBarState, + topBar: (@Composable () -> Unit)?, + bottomBar: (@Composable () -> Unit)?, + floatingButton: (@Composable () -> Unit)?, + mainContent: @Composable (padding: PaddingValues) -> Unit, +) { + SubcomposeLayout { constraints -> + val layoutWidth = constraints.maxWidth + val layoutHeight = constraints.maxHeight + val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0) + + val topPlaceable = + topBar?.let { bar -> + subcompose(DisappearingSlot.Top) { + Column( + modifier = + Modifier.graphicsLayer { + translationY = state.topHeightOffset + }, + ) { + bar() HorizontalDivider(thickness = DividerThickness) } + }.firstOrNull()?.measure(looseConstraints) + } + val topHeight = topPlaceable?.height ?: 0 + + val bottomPlaceable = + bottomBar?.let { bar -> + subcompose(DisappearingSlot.Bottom) { + Column( + modifier = + Modifier.graphicsLayer { + translationY = -state.bottomHeightOffset + }, + ) { + bar() + } + }.firstOrNull()?.measure(looseConstraints) + } + val bottomHeight = bottomPlaceable?.height ?: 0 + + // Publish the measured limits so the nested-scroll connection can clamp correctly. + state.topHeightLimit = topHeight.toFloat() + state.bottomHeightLimit = bottomHeight.toFloat() + + val contentPadding = + PaddingValues( + top = topHeight.toDp(), + bottom = bottomHeight.toDp(), + ) + + val contentPlaceable = + subcompose(DisappearingSlot.Content) { + CompositionLocalProvider(LocalDisappearingScaffoldPadding provides contentPadding) { + mainContent(contentPadding) + } + }.firstOrNull()?.measure( + Constraints.fixed(layoutWidth, layoutHeight), + ) + + val fabPlaceable = + floatingButton?.let { fab -> + subcompose(DisappearingSlot.Fab) { + FloatingButtonHolder(state = state) { fab() } + }.firstOrNull()?.measure(looseConstraints) + } + + val fabEdgePx = FabEdgePadding.roundToPx() + + layout(layoutWidth, layoutHeight) { + contentPlaceable?.place(0, 0) + topPlaceable?.place(0, 0) + bottomPlaceable?.place(0, layoutHeight - bottomHeight) + if (fabPlaceable != null) { + val x = layoutWidth - fabPlaceable.width - fabEdgePx + val yBase = layoutHeight - bottomHeight - fabPlaceable.height - fabEdgePx + fabPlaceable.place(x, yBase) + } + } + } +} + +/** + * Holds the floating button. Scales / fades it with the bottom-bar collapse fraction and + * rides along with the bar via graphicsLayer, so the layout pass is untouched while + * the bar animates. + */ +@Composable +private fun FloatingButtonHolder( + state: DisappearingBarState, + content: @Composable () -> Unit, +) { + Box( + modifier = + Modifier.graphicsLayer { + val visible = (1f - state.bottomCollapsedFraction).coerceAtLeast(0f) + scaleX = visible + scaleY = visible + alpha = visible + translationY = -state.bottomHeightOffset + }, + ) { + content() + } +} + +@Composable +private fun ResetBarsOnResume(state: DisappearingBarState) { + val lifecycleOwner = LocalLifecycleOwner.current + val scope = rememberCoroutineScope() + DisposableEffect(lifecycleOwner, state) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + if (state.topHeightOffset != 0f || state.bottomHeightOffset != 0f) { + scope.launch { state.resetToVisible() } + } } } - }, - floatingActionButton = { - floatingButton?.let { - DisappearingFloatingButton(bottomBehavior) { - it() - } - } - }, - content = mainContent, - ) + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingTopBar.kt deleted file mode 100644 index a70737255f..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingTopBar.kt +++ /dev/null @@ -1,292 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.layouts - -import androidx.compose.animation.core.AnimationSpec -import androidx.compose.animation.core.AnimationState -import androidx.compose.animation.core.DecayAnimationSpec -import androidx.compose.animation.core.animateDecay -import androidx.compose.animation.core.animateTo -import androidx.compose.animation.core.spring -import androidx.compose.animation.rememberSplineBasedDecay -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.draggable -import androidx.compose.foundation.gestures.rememberDraggableState -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.TopAppBarScrollBehavior -import androidx.compose.material3.TopAppBarState -import androidx.compose.material3.rememberTopAppBarState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.Stable -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.layout.layout -import androidx.compose.ui.unit.Velocity -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner -import kotlinx.coroutines.launch -import kotlin.math.abs -import kotlin.math.roundToInt - -@Composable -fun DisappearingTopBar( - scrollBehavior: CustomEnterAlwaysScrollBehavior, - content: @Composable (ColumnScope.() -> Unit), -) { - // ResetDisappearingOnResume(scrollBehavior) - - // Set up support for resizing the top app bar when vertically dragging the bar itself. - val appBarDragModifier = - Modifier - .draggable( - orientation = Orientation.Vertical, - state = - rememberDraggableState { delta -> - scrollBehavior.state.heightOffset += delta - }, - onDragStopped = { velocity -> - settleAppBar( - scrollBehavior.state, - velocity, - scrollBehavior.flingAnimationSpec, - scrollBehavior.snapAnimationSpec, - ) - }, - ) - - Column( - Modifier - .layout { measurable, constraints -> - val placeable = measurable.measure(constraints) - - // Sets the app bar's height offset to collapse the entire bar's height when - // content is scrolled. - scrollBehavior.state.heightOffsetLimit = -placeable.height.toFloat() - val height = placeable.height + scrollBehavior.state.heightOffset - layout(placeable.width, height.roundToInt().coerceAtLeast(0)) { - // slides up together with the reduce in height - placeable.place(0, scrollBehavior.state.heightOffset.roundToInt()) - } - }.then(appBarDragModifier), - content = content, - ) -} - -@Composable -fun ResetDisappearingOnResume(scrollBehavior: CustomEnterAlwaysScrollBehavior) { - // resets bar on resume - val lifeCycleOwner = LocalLifecycleOwner.current - val scope = rememberCoroutineScope() - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME && scrollBehavior.state.heightOffset != 0f) { - val spec = scrollBehavior.snapAnimationSpec - if (spec != null) { - scope.launch { - AnimationState(initialValue = scrollBehavior.state.heightOffset) - .animateTo(0f, animationSpec = spec) { - scrollBehavior.state.heightOffset = value - } - - AnimationState(initialValue = scrollBehavior.state.contentOffset) - .animateTo(0f, animationSpec = spec) { - scrollBehavior.state.contentOffset = value - } - } - } else { - scrollBehavior.state.heightOffset = 0f - scrollBehavior.state.contentOffset = 0f - } - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } -} - -private suspend fun settleAppBar( - state: TopAppBarState, - velocity: Float, - flingAnimationSpec: DecayAnimationSpec?, - snapAnimationSpec: AnimationSpec?, -): Velocity { - // Check if the app bar is completely collapsed/expanded. If so, no need to settle the app bar, - // and just return Zero Velocity. - // Note that we don't check for 0f due to float precision with the collapsedFraction - // calculation. - if (state.collapsedFraction < 0.01f || state.collapsedFraction == 1f) { - return Velocity.Zero - } - var remainingVelocity = velocity - // In case there is an initial velocity that was left after a previous user fling, animate to - // continue the motion to expand or collapse the app bar. - if (flingAnimationSpec != null && abs(velocity) > 1f) { - var lastValue = 0f - AnimationState( - initialValue = 0f, - initialVelocity = velocity, - ).animateDecay(flingAnimationSpec) { - val delta = value - lastValue - val initialHeightOffset = state.heightOffset - state.heightOffset = initialHeightOffset + delta - val consumed = abs(initialHeightOffset - state.heightOffset) - lastValue = value - remainingVelocity = this.velocity - // avoid rounding errors and stop if anything is unconsumed - if (abs(delta - consumed) > 0.5f) this.cancelAnimation() - } - } - // Snap if animation specs were provided. - if (snapAnimationSpec != null) { - if (state.heightOffset < 0 && state.heightOffset > state.heightOffsetLimit) { - AnimationState(initialValue = state.heightOffset).animateTo( - if (state.collapsedFraction < 0.5f) { - 0f - } else { - state.heightOffsetLimit - }, - animationSpec = snapAnimationSpec, - ) { - state.heightOffset = value - } - } - } - - return Velocity(0f, remainingVelocity) -} - -/** - * EnterAlwaysScrollBehavior uses internal Material 3 animation specs - */ -val defaultMaterial3StandardSnap = - spring( - dampingRatio = 1.0f, - stiffness = 1600.0f, - ) - -/** - * Copy of enterAlwaysScrollBehavior to use CustomEnterAlwaysScrollBehavior - */ -@Composable -fun enterAlwaysScrollBehavior( - state: TopAppBarState = rememberTopAppBarState(), - canScroll: () -> Boolean = { true }, - // TODO Load the motionScheme tokens from the component tokens file - snapAnimationSpec: AnimationSpec? = defaultMaterial3StandardSnap, - flingAnimationSpec: DecayAnimationSpec? = rememberSplineBasedDecay(), - reverseLayout: Boolean = false, -): CustomEnterAlwaysScrollBehavior = - remember(state, canScroll, snapAnimationSpec, flingAnimationSpec) { - CustomEnterAlwaysScrollBehavior( - state = state, - snapAnimationSpec = snapAnimationSpec, - flingAnimationSpec = flingAnimationSpec, - canScroll = canScroll, - reverseLayout = reverseLayout, - ) - } - -/** - * Custom copy of EnterAlwaysScrollBehavior that correctly handles reversed layouts - */ -@OptIn(ExperimentalMaterial3Api::class) -@Stable -class CustomEnterAlwaysScrollBehavior( - override val state: TopAppBarState, - override val snapAnimationSpec: AnimationSpec?, - override val flingAnimationSpec: DecayAnimationSpec?, - val canScroll: () -> Boolean = { true }, - val reverseLayout: Boolean = false, -) : TopAppBarScrollBehavior { - override val isPinned: Boolean = false - override var nestedScrollConnection = - object : NestedScrollConnection { - override fun onPreScroll( - available: Offset, - source: NestedScrollSource, - ): Offset { - if (!canScroll()) return Offset.Zero - val prevHeightOffset = state.heightOffset - - state.heightOffset += - if (reverseLayout) { - -available.y - } else { - available.y - } - - // The state's heightOffset is coerce in a minimum value of heightOffsetLimit and a - // maximum value 0f, so we check if its value was actually changed after the - // available.y was added to it in order to tell if the top app bar is currently - // collapsing or expanding. - // Note that when the content was set with a revered layout, we always return a - // zero offset. - return if (!reverseLayout && prevHeightOffset != state.heightOffset) { - available.copy(x = 0f) - } else { - Offset.Zero - } - } - - override fun onPostScroll( - consumed: Offset, - available: Offset, - source: NestedScrollSource, - ): Offset { - if (!canScroll()) return Offset.Zero - state.contentOffset += consumed.y - state.heightOffset += - if (reverseLayout) { - -consumed.y - } else { - consumed.y - } - return Offset.Zero - } - - override suspend fun onPostFling( - consumed: Velocity, - available: Velocity, - ): Velocity { - val hasVelocityLeft = if (reverseLayout) available.y < 0f else available.y > 0f - if ( - hasVelocityLeft && - (state.heightOffset == 0f || state.heightOffset == state.heightOffsetLimit) - ) { - // Reset the total content offset to zero when scrolling all the way down. - // This will eliminate some float precision inaccuracies. - state.contentOffset = 0f - } - val superConsumed = super.onPostFling(consumed, available) - return superConsumed + settleAppBar(state, available.y, flingAnimationSpec, snapAnimationSpec) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/PaddingMerge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/PaddingMerge.kt new file mode 100644 index 0000000000..3dad27520f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/PaddingMerge.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.layouts + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.dp + +/** + * The padding the surrounding [DisappearingScaffold] would like its inner scrollable + * to apply as `contentPadding`. Defaults to [PaddingValues] of 0 when no scaffold is + * providing it, so feed composables used outside a scaffold behave as before. + * + * Read it via [rememberFeedContentPadding] to merge with the list's own + * baseline padding (typically `FeedPadding`). + */ +val LocalDisappearingScaffoldPadding = compositionLocalOf { PaddingValues(0.dp) } + +/** + * Merges two [PaddingValues] component-wise, resolving start/end against the current + * [LocalLayoutDirection]. + */ +@Composable +fun rememberMergedPadding( + outer: PaddingValues, + inner: PaddingValues, +): PaddingValues { + val layoutDirection = LocalLayoutDirection.current + return remember(outer, inner, layoutDirection) { + PaddingValues( + start = outer.calculateStartPadding(layoutDirection) + inner.calculateStartPadding(layoutDirection), + top = outer.calculateTopPadding() + inner.calculateTopPadding(), + end = outer.calculateEndPadding(layoutDirection) + inner.calculateEndPadding(layoutDirection), + bottom = outer.calculateBottomPadding() + inner.calculateBottomPadding(), + ) + } +} + +/** + * Convenience for inner LazyColumns/LazyVerticalGrids inside a [DisappearingScaffold]: + * merges the scaffold's reserved space with the list's own baseline padding. + */ +@Composable +fun rememberFeedContentPadding(inner: PaddingValues): PaddingValues = rememberMergedPadding(LocalDisappearingScaffoldPadding.current, inner) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt index 0b2ac67dd5..f98f1c3679 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.navigation.bottombars import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.RowScope @@ -71,6 +72,7 @@ private fun RenderBottomMenu( modifier = Modifier .fillMaxWidth() + .background(MaterialTheme.colorScheme.background) .windowInsetsPadding(windowInsets) .consumeWindowInsets(windowInsets) .height(50.dp), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt index 652c44eb60..9b7a5895b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.UserCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -91,7 +92,7 @@ private fun FeedLoaded( LazyColumn( modifier = Modifier.fillMaxSize(), - contentPadding = FeedPadding, + contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, ) { itemsIndexed(items, key = { _, item -> item.pubkeyHex }) { _, item -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesFeedLoaded.kt index b2f20754a1..534bc03854 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesFeedLoaded.kt @@ -31,6 +31,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.ChannelCardCompose @@ -48,7 +49,7 @@ fun ArticlesFeedLoaded( val items by loaded.feed.collectAsStateWithLifecycle() LazyColumn( - contentPadding = FeedPadding, + contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, ) { itemsIndexed( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesScreen.kt index 43a04ebfb9..4a88641cf2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesScreen.kt @@ -20,12 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.articles -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox @@ -80,26 +77,24 @@ fun ArticlesScreen( NewArticleButton(nav) }, accountViewModel = accountViewModel, - ) { paddingValues -> - Column(Modifier.padding(paddingValues)) { - RefresheableBox(articlesFeedContentState, true) { - SaveableFeedContentState(articlesFeedContentState, scrollStateKey = ScrollStateKeys.ARTICLES_SCREEN) { listState -> - RenderFeedContentState( - feedContentState = articlesFeedContentState, - accountViewModel = accountViewModel, - listState = listState, - nav = nav, - routeForLastRead = "ArticlesFeed", - onLoaded = { loaded -> - ArticlesFeedLoaded( - loaded = loaded, - listState = listState, - accountViewModel = accountViewModel, - nav = nav, - ) - }, - ) - } + ) { + RefresheableBox(articlesFeedContentState, true) { + SaveableFeedContentState(articlesFeedContentState, scrollStateKey = ScrollStateKeys.ARTICLES_SCREEN) { listState -> + RenderFeedContentState( + feedContentState = articlesFeedContentState, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = "ArticlesFeed", + onLoaded = { loaded -> + ArticlesFeedLoaded( + loaded = loaded, + listState = listState, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesScreen.kt index 19cf4ed9f2..8080df3a82 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/BadgesScreen.kt @@ -20,12 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox @@ -80,18 +77,16 @@ fun BadgesScreen( NewBadgeButton(accountViewModel) }, accountViewModel = accountViewModel, - ) { paddingValues -> - Column(Modifier.padding(paddingValues)) { - RefresheableBox(feedContentState, true) { - SaveableFeedContentState(feedContentState, scrollStateKey = ScrollStateKeys.BADGES_SCREEN) { listState -> - RenderFeedContentState( - feedContentState = feedContentState, - accountViewModel = accountViewModel, - listState = listState, - nav = nav, - routeForLastRead = "BadgesFeed", - ) - } + ) { + RefresheableBox(feedContentState, true) { + SaveableFeedContentState(feedContentState, scrollStateKey = ScrollStateKeys.BADGES_SCREEN) { listState -> + RenderFeedContentState( + feedContentState = feedContentState, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = "BadgesFeed", + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt index f32fd36849..64e5ae1512 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt @@ -21,8 +21,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState @@ -38,8 +40,8 @@ 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 androidx.compose.ui.graphics.Color import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R @@ -129,7 +131,7 @@ private fun RenderBookmarkScreen( Column { TopBarWithBackButton(stringRes(id = R.string.bookmarks_title), nav::popBack) SecondaryTabRow( - containerColor = Color.Transparent, + containerColor = MaterialTheme.colorScheme.background, contentColor = MaterialTheme.colorScheme.onBackground, selectedTabIndex = pagerState.currentPage, modifier = TabRowHeight, @@ -148,22 +150,9 @@ private fun RenderBookmarkScreen( } }, accountViewModel = accountViewModel, - ) { - Column(Modifier.padding(it).fillMaxHeight()) { - if (!bannerDismissed) { - DeletedItemsBanner( - count = deletedCount, - onRemove = { - accountViewModel.removeDeletedBookmarks( - deletedEventIds.toSet(), - deletedAddresses.toSet(), - ) - bannerDismissed = true - }, - onDismiss = { bannerDismissed = true }, - ) - } - HorizontalPager(state = pagerState) { page -> + ) { paddingValues -> + Box(modifier = Modifier.fillMaxSize()) { + HorizontalPager(state = pagerState, modifier = Modifier.fillMaxHeight()) { page -> when (page) { 0 -> { RefresheableFeedView( @@ -184,6 +173,24 @@ private fun RenderBookmarkScreen( } } } + + if (!bannerDismissed && deletedCount > 0) { + DeletedItemsBanner( + count = deletedCount, + onRemove = { + accountViewModel.removeDeletedBookmarks( + deletedEventIds.toSet(), + deletedAddresses.toSet(), + ) + bannerDismissed = true + }, + onDismiss = { bannerDismissed = true }, + modifier = + Modifier + .align(Alignment.TopCenter) + .padding(top = paddingValues.calculateTopPadding()), + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt index 6ef2e1fd2e..c4eb079e32 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt @@ -51,7 +51,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -351,7 +350,7 @@ fun BookmarkGroupHeaderTabs( } SecondaryTabRow( - containerColor = Color.Transparent, + containerColor = MaterialTheme.colorScheme.background, contentColor = MaterialTheme.colorScheme.onBackground, selectedTabIndex = pagerState.currentPage, modifier = TabRowHeight, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/OldBookmarkListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/OldBookmarkListScreen.kt index e4d281a92f..e80abeca44 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/OldBookmarkListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/old/OldBookmarkListScreen.kt @@ -23,8 +23,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old import android.annotation.SuppressLint import android.widget.Toast import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState @@ -44,8 +46,8 @@ 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 androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel @@ -138,7 +140,7 @@ private fun RenderOldBookmarkScreen( Column { TopBarWithBackButton(stringRes(id = R.string.old_bookmarks_title), nav::popBack) SecondaryTabRow( - containerColor = Color.Transparent, + containerColor = MaterialTheme.colorScheme.background, contentColor = MaterialTheme.colorScheme.onBackground, selectedTabIndex = pagerState.currentPage, modifier = TabRowHeight, @@ -182,22 +184,9 @@ private fun RenderOldBookmarkScreen( ) }, accountViewModel = accountViewModel, - ) { - Column(Modifier.padding(it).fillMaxHeight()) { - if (!bannerDismissed) { - DeletedItemsBanner( - count = deletedCount, - onRemove = { - accountViewModel.removeDeletedOldBookmarks( - deletedEventIds.toSet(), - deletedAddresses.toSet(), - ) - bannerDismissed = true - }, - onDismiss = { bannerDismissed = true }, - ) - } - HorizontalPager(state = pagerState) { page -> + ) { paddingValues -> + Box(modifier = Modifier.fillMaxSize()) { + HorizontalPager(state = pagerState, modifier = Modifier.fillMaxHeight()) { page -> when (page) { 0 -> { RefresheableFeedView( @@ -218,6 +207,24 @@ private fun RenderOldBookmarkScreen( } } } + + if (!bannerDismissed && deletedCount > 0) { + DeletedItemsBanner( + count = deletedCount, + onRemove = { + accountViewModel.removeDeletedOldBookmarks( + deletedEventIds.toSet(), + deletedAddresses.toSet(), + ) + bannerDismissed = true + }, + onDismiss = { bannerDismissed = true }, + modifier = + Modifier + .align(Alignment.TopCenter) + .padding(top = paddingValues.calculateTopPadding()), + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt index 7e4ded1880..99b5e86a09 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt @@ -98,6 +98,7 @@ fun MarmotGroupChatScreen( ) }, accountViewModel = accountViewModel, + allowBarHide = false, ) { Column(Modifier.padding(it).consumeWindowInsets(it).statusBarsPadding()) { MarmotGroupChatView( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomByAuthorScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomByAuthorScreen.kt index 4d4a276be6..d95cbef9da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomByAuthorScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomByAuthorScreen.kt @@ -51,6 +51,7 @@ fun ChatroomByAuthorScreen( RoomByAuthorTopBar(authorPubKeyHex, accountViewModel, nav) }, accountViewModel = accountViewModel, + allowBarHide = false, ) { Column(Modifier.padding(it)) { ChatroomByAuthor(authorPubKeyHex, draftMessage, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt index 06830903bb..a12f41959c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt @@ -82,6 +82,7 @@ fun ChatroomScreen( ) }, accountViewModel = accountViewModel, + allowBarHide = false, ) { Column(Modifier.padding(it).consumeWindowInsets(it).statusBarsPadding()) { ChatroomView( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/EphemeralChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/EphemeralChatScreen.kt index 6fe892d32c..5ee9bb87ec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/EphemeralChatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/EphemeralChatScreen.kt @@ -55,6 +55,7 @@ fun EphemeralChatScreen( } }, accountViewModel = accountViewModel, + allowBarHide = false, ) { Column(Modifier.padding(it)) { EphemeralChatChannelView(channelId, draft, replyTo, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt index 1132fd56f1..d4286b56f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt @@ -53,6 +53,7 @@ fun PublicChatChannelScreen( } }, accountViewModel = accountViewModel, + allowBarHide = false, ) { Column(Modifier.padding(it)) { PublicChatChannelView(channelId, draft, replyTo, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt index d04ad5cb38..4f4cee53f3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt @@ -61,6 +61,7 @@ fun LiveActivityChannelScreen( } }, accountViewModel = accountViewModel, + allowBarHide = false, ) { Column(Modifier.padding(it)) { LiveActivityChannelView(channelId, draft, replyTo, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt index 60d6fc1421..b702de4d4e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState +import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChatroomHeaderCompose @@ -103,7 +104,7 @@ private fun FeedLoaded( val items by loaded.feed.collectAsStateWithLifecycle() LazyColumn( - contentPadding = FeedPadding, + contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, ) { itemsIndexed( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt index d168e99edb..45b1e85837 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.pager.HorizontalPager @@ -46,7 +45,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.components.M3ActionDialog @@ -81,7 +79,7 @@ fun MessagesTabHeader( Box(Modifier.fillMaxWidth()) { SecondaryTabRow( - containerColor = Color.Transparent, + containerColor = MaterialTheme.colorScheme.background, contentColor = MaterialTheme.colorScheme.onBackground, selectedTabIndex = pagerState.currentPage, modifier = TabRowHeight, @@ -123,12 +121,10 @@ fun MessagesTabHeader( fun MessagesPager( pagerState: PagerState, tabs: List, - paddingValues: PaddingValues, accountViewModel: AccountViewModel, nav: INav, ) { HorizontalPager( - contentPadding = paddingValues, state = pagerState, userScrollEnabled = true, modifier = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt index f2c215e082..b56a5e12c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt @@ -98,7 +98,6 @@ fun MessagesSinglePane( MessagesPager( pagerState, tabs, - it, accountViewModel, nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/ChatroomListPane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/ChatroomListPane.kt index 14928b2470..cadfd00a4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/ChatroomListPane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/ChatroomListPane.kt @@ -21,13 +21,11 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.twopane import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys @@ -75,7 +73,6 @@ fun ChatroomList( MessagesPager( pagerState, tabs, - PaddingValues(0.dp), accountViewModel, nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt index 1e187d6145..867b4788dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.twopane import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding @@ -134,7 +133,7 @@ fun MessagesTwoPane( strategy = strategy, displayFeatures = displayFeatures, foldAwareConfiguration = FoldAwareConfiguration.VerticalFoldsOnly, - modifier = Modifier.padding(padding).consumeWindowInsets(padding).fillMaxSize(), + modifier = Modifier.fillMaxSize(), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/CommunityScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/CommunityScreen.kt index 3edfcadb8e..faf7432320 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/CommunityScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/CommunityScreen.kt @@ -40,7 +40,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R @@ -168,7 +167,7 @@ fun CommunityScreen( } SecondaryTabRow( - containerColor = Color.Transparent, + containerColor = MaterialTheme.colorScheme.background, contentColor = MaterialTheme.colorScheme.onBackground, modifier = TabRowHeight, selectedTabIndex = pagerState.currentPage, @@ -192,7 +191,6 @@ fun CommunityScreen( accountViewModel = accountViewModel, ) { HorizontalPager( - contentPadding = it, state = pagerState, ) { page -> when (page) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt index 4620d750aa..27e5c01516 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt @@ -69,6 +69,7 @@ import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -220,7 +221,7 @@ private fun DiscoverPages( Column { DiscoveryTopBar(accountViewModel, nav) SecondaryScrollableTabRow( - containerColor = Color.Transparent, + containerColor = MaterialTheme.colorScheme.background, contentColor = MaterialTheme.colorScheme.onBackground, selectedTabIndex = pagerState.currentPage, modifier = TabRowHeight, @@ -264,7 +265,7 @@ private fun DiscoverPages( }, accountViewModel = accountViewModel, ) { - HorizontalPager(state = pagerState, contentPadding = it) { page -> + HorizontalPager(state = pagerState) { page -> if (page >= 0 && page < feedTabs.size) { val tab = feedTabs[page] RefresheableBox(tab.feedState, true) { @@ -466,7 +467,7 @@ private fun DiscoverFeedLoaded( val items by loaded.feed.collectAsStateWithLifecycle() LazyColumn( - contentPadding = FeedPadding, + contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, ) { itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item -> @@ -502,7 +503,7 @@ private fun DiscoverFeedColumnsLoaded( LazyVerticalGrid( columns = GridCells.Fixed(2), - contentPadding = FeedPadding, + contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, ) { itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt index 8d6df52e0b..0c9f326d8f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/drafts/DraftListScreen.kt @@ -21,11 +21,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed @@ -56,6 +53,7 @@ import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys.DRAFTS import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon @@ -148,18 +146,16 @@ private fun RenderDraftListScreen( }, accountViewModel = accountViewModel, ) { - Column(Modifier.padding(it).fillMaxHeight()) { - RefresheableBox(feedState) { - SaveableFeedState(feedState, DRAFTS) { listState -> - RenderFeedContentState( - feedContentState = feedState, - accountViewModel = accountViewModel, - listState = listState, - nav = nav, - routeForLastRead = null, - onLoaded = { DraftFeedLoaded(it, listState, accountViewModel, nav) }, - ) - } + RefresheableBox(feedState) { + SaveableFeedState(feedState, DRAFTS) { listState -> + RenderFeedContentState( + feedContentState = feedState, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = null, + onLoaded = { DraftFeedLoaded(it, listState, accountViewModel, nav) }, + ) } } } @@ -175,7 +171,7 @@ private fun DraftFeedLoaded( val items by loaded.feed.collectAsStateWithLifecycle() LazyColumn( - contentPadding = FeedPadding, + contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, ) { itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt index 3f5dea38e2..b112e5264a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt @@ -24,7 +24,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button @@ -96,21 +95,19 @@ fun DvmContentDiscoveryScreen( DvmTopBar(appDefinitionEventId, accountViewModel, nav) }, accountViewModel = accountViewModel, - ) { paddingValues -> - Column(Modifier.padding(paddingValues)) { - LoadNote(baseNoteHex = appDefinitionEventId, accountViewModel = accountViewModel) { note -> - note?.let { baseNote -> - WatchNoteEvent( - baseNote, - onNoteEventFound = { - DvmContentDiscoveryScreen(baseNote, accountViewModel, nav) - }, - onBlank = { - FeedEmptyWithStatus(baseNote, stringRes(R.string.dvm_looking_for_app), accountViewModel, nav) - }, - accountViewModel, - ) - } + ) { + LoadNote(baseNoteHex = appDefinitionEventId, accountViewModel = accountViewModel) { note -> + note?.let { baseNote -> + WatchNoteEvent( + baseNote, + onNoteEventFound = { + DvmContentDiscoveryScreen(baseNote, accountViewModel, nav) + }, + onBlank = { + FeedEmptyWithStatus(baseNote, stringRes(R.string.dvm_looking_for_app), accountViewModel, nav) + }, + accountViewModel, + ) } } } @@ -272,22 +269,20 @@ fun RenderNostrNIP90ContentDiscoveryScreen( accountViewModel: AccountViewModel, nav: INav, ) { - Column(Modifier.fillMaxHeight()) { - SaveableFeedState(resultFeedViewModel.feedState, null) { listState -> - // TODO (Optional) Instead of a like reaction, do a Kind 31989 NIP89 App recommendation - RenderFeedState( - resultFeedViewModel, - accountViewModel, - listState, - nav, - null, - onEmpty = { - FeedEmpty { - onRefresh() - } - }, - ) - } + SaveableFeedState(resultFeedViewModel.feedState, null) { listState -> + // TODO (Optional) Instead of a like reaction, do a Kind 31989 NIP89 App recommendation + RenderFeedState( + resultFeedViewModel, + accountViewModel, + listState, + nav, + null, + onEmpty = { + FeedEmpty { + onRefresh() + } + }, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/FollowPackFeedScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/FollowPackFeedScreen.kt index 25bd378ba9..f9c7024034 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/FollowPackFeedScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/FollowPackFeedScreen.kt @@ -172,7 +172,6 @@ fun FollowPackFeedScreen( accountViewModel = accountViewModel, ) { HorizontalPager( - contentPadding = it, state = pagerState, ) { page -> when (page) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashScreen.kt index 96872d1a1c..e8d851da69 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashScreen.kt @@ -21,8 +21,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash import android.annotation.SuppressLint -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -102,14 +100,12 @@ fun GeoHashScreen( }, accountViewModel = accountViewModel, ) { - Column(Modifier.padding(it)) { - RefresheableFeedView( - feedViewModel, - null, - accountViewModel = accountViewModel, - nav = nav, - ) - } + RefresheableFeedView( + feedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagScreen.kt index f5b862ecb6..d3d640f331 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagScreen.kt @@ -23,10 +23,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag import android.annotation.SuppressLint import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -107,14 +105,12 @@ fun HashtagScreen( }, accountViewModel = accountViewModel, ) { - Column(Modifier.padding(it)) { - RefresheableFeedView( - feedViewModel, - null, - accountViewModel = accountViewModel, - nav = nav, - ) - } + RefresheableFeedView( + feedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 25a39e1ccb..0f679573f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -25,7 +25,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -53,9 +52,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R @@ -78,6 +75,7 @@ import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -184,7 +182,7 @@ private fun HomePages( Column { HomeTopBar(accountViewModel, nav) SecondaryTabRow( - containerColor = Color.Transparent, + containerColor = MaterialTheme.colorScheme.background, contentColor = MaterialTheme.colorScheme.onBackground, modifier = TabRowHeight, selectedTabIndex = pagerState.currentPage, @@ -217,11 +215,11 @@ private fun HomePages( // Wrap pager + banner in a Box so the banner can float over the feed // (anchored top-center) instead of living in the topBar Column where // it would push the tabs down every time it appears or disappears. - Box( - modifier = Modifier.fillMaxSize().padding(paddingValues), - ) { + // The Box itself fills the full screen; inner LazyColumns pick up the + // scaffold padding from LocalDisappearingScaffoldPadding via + // rememberFeedContentPadding, so feed items still scroll behind the bars. + Box(modifier = Modifier.fillMaxSize()) { HorizontalPager( - contentPadding = PaddingValues(0.dp), state = pagerState, userScrollEnabled = true, modifier = @@ -243,7 +241,10 @@ private fun HomePages( HomeAlgoFeedStatusBanner( accountViewModel = accountViewModel, nav = nav, - modifier = Modifier.align(Alignment.TopCenter), + modifier = + Modifier + .align(Alignment.TopCenter) + .padding(top = paddingValues.calculateTopPadding()), ) } } @@ -362,7 +363,7 @@ fun FeedLoaded( val items by loaded.feed.collectAsStateWithLifecycle() LazyColumn( - contentPadding = FeedPadding, + contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, ) { if (liveSection != null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewNoteButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewNoteButton.kt index e96c8e744a..298d5f5263 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewNoteButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewNoteButton.kt @@ -21,11 +21,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.BottomAppBarDefaults -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -36,7 +33,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.layouts.DisappearingFloatingButton import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -46,25 +42,12 @@ import com.vitorpamplona.amethyst.ui.theme.Size26Modifier import com.vitorpamplona.amethyst.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow -@OptIn(ExperimentalMaterial3Api::class) @Preview @Composable fun NewNoteButtonPreview() { - val bottomBehavior = - BottomAppBarDefaults.exitAlwaysScrollBehavior( - canScroll = { true }, - ) - ThemeComparisonRow { - Column { - Box(Modifier.size(200.dp), contentAlignment = Alignment.Center) { - DisappearingFloatingButton(bottomBehavior) { - NewNoteButton(EmptyNav()) - } - } - Box(Modifier.size(200.dp), contentAlignment = Alignment.Center) { - NewNoteButton(EmptyNav()) - } + Box(Modifier.size(200.dp), contentAlignment = Alignment.Center) { + NewNoteButton(EmptyNav()) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsFeedLoaded.kt index 23639b9606..c35455894d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsFeedLoaded.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.VideoCardCompose @@ -49,7 +50,7 @@ fun LongsFeedLoaded( val items by loaded.feed.collectAsStateWithLifecycle() LazyColumn( - contentPadding = FeedPadding, + contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, ) { itemsIndexed( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsScreen.kt index f79711e742..002e483e4e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsScreen.kt @@ -20,12 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.longs -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox @@ -80,26 +77,24 @@ fun LongsScreen( NewLongVideoButton(accountViewModel, nav, longsFeedContentState::sendToTop) }, accountViewModel = accountViewModel, - ) { paddingValues -> - Column(Modifier.padding(paddingValues)) { - RefresheableBox(longsFeedContentState, true) { - SaveableFeedContentState(longsFeedContentState, scrollStateKey = ScrollStateKeys.LONGS_SCREEN) { listState -> - RenderFeedContentState( - feedContentState = longsFeedContentState, - accountViewModel = accountViewModel, - listState = listState, - nav = nav, - routeForLastRead = "LongsFeed", - onLoaded = { loaded -> - LongsFeedLoaded( - loaded = loaded, - listState = listState, - accountViewModel = accountViewModel, - nav = nav, - ) - }, - ) - } + ) { + RefresheableBox(longsFeedContentState, true) { + SaveableFeedContentState(longsFeedContentState, scrollStateKey = ScrollStateKeys.LONGS_SCREEN) { listState -> + RenderFeedContentState( + feedContentState = longsFeedContentState, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = "LongsFeed", + onLoaded = { loaded -> + LongsFeedLoaded( + loaded = loaded, + listState = listState, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt index d7dd94423a..54cb9cfda2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt @@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed +import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.BadgeCompose import com.vitorpamplona.amethyst.ui.note.CloseIcon @@ -86,6 +87,7 @@ fun RenderCardFeed( nav: INav, routeForLastRead: String, scrollToEventId: String? = null, + headerContent: (@Composable () -> Unit)? = null, ) { val feedState by feedContent.feedContent.collectAsStateWithLifecycle() @@ -113,6 +115,7 @@ fun RenderCardFeed( accountViewModel = accountViewModel, nav = nav, scrollToEventId = scrollToEventId, + headerContent = headerContent, ) } @@ -146,6 +149,7 @@ private fun FeedLoaded( accountViewModel: AccountViewModel, nav: INav, scrollToEventId: String? = null, + headerContent: (@Composable () -> Unit)? = null, ) { val items by loaded.feed.collectAsStateWithLifecycle() val openPolls by polls.flow.collectAsStateWithLifecycle() @@ -170,9 +174,12 @@ private fun FeedLoaded( LazyColumn( modifier = Modifier.fillMaxSize(), - contentPadding = FeedPadding, + contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, ) { + if (headerContent != null) { + item(key = "scaffold-header") { headerContent() } + } item { ShowDonationCard(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt index 41d1f41833..6ff66075d2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt @@ -21,12 +21,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.model.UiSettingsFlow import com.vitorpamplona.amethyst.ui.components.SelectNotificationProvider @@ -92,17 +89,21 @@ fun NotificationScreen( }, accountViewModel = accountViewModel, ) { - Column( - modifier = Modifier.padding(it).consumeWindowInsets(it), - ) { - ObserveInboxRelayListAndDisplayIfNotFound(accountViewModel, nav) - RefresheableBox(notifFeedContentState, true) { - val listState = rememberForeverLazyListState(ScrollStateKeys.NOTIFICATION_SCREEN) + RefresheableBox(notifFeedContentState, true) { + val listState = rememberForeverLazyListState(ScrollStateKeys.NOTIFICATION_SCREEN) - WatchScrollToTop(notifFeedContentState, listState) + WatchScrollToTop(notifFeedContentState, listState) - RenderCardFeed(notifFeedContentState, notifPolls, accountViewModel, listState, nav, "Notification", scrollToEventId) - } + RenderCardFeed( + feedContent = notifFeedContentState, + pollContent = notifPolls, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = "Notification", + scrollToEventId = scrollToEventId, + headerContent = { ObserveInboxRelayListAndDisplayIfNotFound(accountViewModel, nav) }, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureFeedLoaded.kt index f6c61424a7..c9d972238e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureFeedLoaded.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -48,7 +49,7 @@ fun PictureFeedLoaded( val items by loaded.feed.collectAsStateWithLifecycle() LazyColumn( - contentPadding = FeedPadding, + contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, ) { itemsIndexed( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PicturesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PicturesScreen.kt index 89b1629d6f..cfb27f98b2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PicturesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PicturesScreen.kt @@ -20,12 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox @@ -80,26 +77,24 @@ fun PicturesScreen( NewPictureButton(accountViewModel, nav, picturesFeedContentState::sendToTop) }, accountViewModel = accountViewModel, - ) { paddingValues -> - Column(Modifier.padding(paddingValues)) { - RefresheableBox(picturesFeedContentState, true) { - SaveableFeedContentState(picturesFeedContentState, scrollStateKey = ScrollStateKeys.PICTURES_SCREEN) { listState -> - RenderFeedContentState( - feedContentState = picturesFeedContentState, - accountViewModel = accountViewModel, - listState = listState, - nav = nav, - routeForLastRead = "PicturesFeed", - onLoaded = { loaded -> - PictureFeedLoaded( - loaded = loaded, - listState = listState, - accountViewModel = accountViewModel, - nav = nav, - ) - }, - ) - } + ) { + RefresheableBox(picturesFeedContentState, true) { + SaveableFeedContentState(picturesFeedContentState, scrollStateKey = ScrollStateKeys.PICTURES_SCREEN) { listState -> + RenderFeedContentState( + feedContentState = picturesFeedContentState, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = "PicturesFeed", + onLoaded = { loaded -> + PictureFeedLoaded( + loaded = loaded, + listState = listState, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pinnednotes/PinnedNotesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pinnednotes/PinnedNotesScreen.kt index 31c89cb5c9..6446948d2b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pinnednotes/PinnedNotesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pinnednotes/PinnedNotesScreen.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.pinnednotes -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -30,6 +30,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel @@ -95,9 +96,19 @@ private fun RenderPinnedNotesScreen( TopBarWithBackButton(stringRes(id = R.string.pinned_notes), nav::popBack) }, accountViewModel = accountViewModel, - ) { - Column(Modifier.padding(it).fillMaxHeight()) { - if (!bannerDismissed) { + ) { paddingValues -> + // Feed fills the screen so items scroll behind the top bar (via + // rememberFeedContentPadding); banner overlays at the top, offset down + // by the scaffold's top padding so it sits below the bar. + Box(modifier = Modifier.fillMaxSize()) { + RefresheableFeedView( + pinnedNotesFeedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav, + ) + + if (!bannerDismissed && deletedPins.isNotEmpty()) { DeletedItemsBanner( count = deletedPins.size, onRemove = { @@ -105,14 +116,12 @@ private fun RenderPinnedNotesScreen( bannerDismissed = true }, onDismiss = { bannerDismissed = true }, + modifier = + Modifier + .align(Alignment.TopCenter) + .padding(top = paddingValues.calculateTopPadding()), ) } - RefresheableFeedView( - pinnedNotesFeedViewModel, - null, - accountViewModel = accountViewModel, - nav = nav, - ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/PollsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/PollsScreen.kt index 0b512d29c2..09815d9a7d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/PollsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/PollsScreen.kt @@ -34,7 +34,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.graphics.Color import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState @@ -131,7 +130,7 @@ private fun PollsPages( Column { PollsTopBar(accountViewModel, nav) SecondaryTabRow( - containerColor = Color.Transparent, + containerColor = MaterialTheme.colorScheme.background, contentColor = MaterialTheme.colorScheme.onBackground, modifier = TabRowHeight, selectedTabIndex = pagerState.currentPage, @@ -162,7 +161,6 @@ private fun PollsPages( accountViewModel = accountViewModel, ) { HorizontalPager( - contentPadding = it, state = pagerState, userScrollEnabled = true, ) { page -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsFeedLoaded.kt index 68dbb1af9d..4d50129921 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsFeedLoaded.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed +import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.ChannelCardCompose @@ -98,7 +99,7 @@ private fun ProductsFeedColumnsLoaded( LazyVerticalGrid( columns = GridCells.Fixed(2), - contentPadding = FeedPadding, + contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, ) { itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsScreen.kt index 4d2e121605..5bb9ac321c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsScreen.kt @@ -20,12 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.products -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox @@ -79,17 +76,15 @@ fun ProductsScreen( NewProductButton(accountViewModel, nav) }, accountViewModel = accountViewModel, - ) { paddingValues -> - Column(Modifier.padding(paddingValues)) { - RefresheableBox(productsFeedContentState, true) { - SaveableGridFeedContentState(productsFeedContentState, scrollStateKey = ScrollStateKeys.PRODUCTS_SCREEN) { gridState -> - RenderProductsFeed( - feedContentState = productsFeedContentState, - gridState = gridState, - accountViewModel = accountViewModel, - nav = nav, - ) - } + ) { + RefresheableBox(productsFeedContentState, true) { + SaveableGridFeedContentState(productsFeedContentState, scrollStateKey = ScrollStateKeys.PRODUCTS_SCREEN) { gridState -> + RenderProductsFeed( + feedContentState = productsFeedContentState, + gridState = gridState, + accountViewModel = accountViewModel, + nav = nav, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/RelayFeedScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/RelayFeedScreen.kt index fa56b9875d..837e0ad444 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/RelayFeedScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/RelayFeedScreen.kt @@ -21,8 +21,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relay import android.annotation.SuppressLint -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -105,14 +103,12 @@ fun RelayFeedScreen( }, accountViewModel = accountViewModel, ) { - Column(Modifier.padding(it)) { - RefresheableFeedView( - feedViewModel, - null, - accountViewModel = accountViewModel, - nav = nav, - ) - } + RefresheableFeedView( + feedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt index 2dffe5d4c7..a4e0bdf8ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt @@ -22,9 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.search import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth @@ -59,6 +57,7 @@ import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo import com.vitorpamplona.amethyst.service.relayClient.searchCommand.TextSearchDataSourceSubscription import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -124,12 +123,12 @@ fun SearchScreen( }, accountViewModel = accountViewModel, ) { - Column( - modifier = Modifier.padding(it).consumeWindowInsets(it), - ) { - ObserveRelayListForSearchAndDisplayIfNotFound(accountViewModel, nav) - DisplaySearchResults(searchBarViewModel, nav, accountViewModel) - } + DisplaySearchResults( + searchBarViewModel = searchBarViewModel, + headerContent = { ObserveRelayListForSearchAndDisplayIfNotFound(accountViewModel, nav) }, + nav = nav, + accountViewModel = accountViewModel, + ) } } @@ -219,13 +218,11 @@ private fun SearchTextField( @Composable private fun DisplaySearchResults( searchBarViewModel: SearchBarViewModel, + headerContent: @Composable () -> Unit, nav: INav, accountViewModel: AccountViewModel, ) { - if (!searchBarViewModel.isRefreshing.value) { - return - } - + val isRefreshing by searchBarViewModel.isRefreshing val hashTags by searchBarViewModel.hashtagResults.collectAsStateWithLifecycle() val relays by searchBarViewModel.relayResults.collectAsStateWithLifecycle() val users by searchBarViewModel.searchResultsUsers.collectAsStateWithLifecycle() @@ -236,9 +233,13 @@ private fun DisplaySearchResults( LazyColumn( modifier = Modifier.fillMaxHeight(), - contentPadding = FeedPadding, + contentPadding = rememberFeedContentPadding(FeedPadding), state = searchBarViewModel.listState, ) { + item(key = "scaffold-header") { headerContent() } + + if (!isRefreshing) return@LazyColumn + itemsIndexed( hashTags, key = { _, item -> "#$item" }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsFeedLoaded.kt index e68e075be0..529334e299 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsFeedLoaded.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -48,7 +49,7 @@ fun ShortsFeedLoaded( val items by loaded.feed.collectAsStateWithLifecycle() LazyColumn( - contentPadding = FeedPadding, + contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, ) { itemsIndexed( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsScreen.kt index a993b7f167..be9cda0592 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsScreen.kt @@ -20,12 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox @@ -80,26 +77,24 @@ fun ShortsScreen( NewShortVideoButton(accountViewModel, nav, shortsFeedContentState::sendToTop) }, accountViewModel = accountViewModel, - ) { paddingValues -> - Column(Modifier.padding(paddingValues)) { - RefresheableBox(shortsFeedContentState, true) { - SaveableFeedContentState(shortsFeedContentState, scrollStateKey = ScrollStateKeys.SHORTS_SCREEN) { listState -> - RenderFeedContentState( - feedContentState = shortsFeedContentState, - accountViewModel = accountViewModel, - listState = listState, - nav = nav, - routeForLastRead = "ShortsFeed", - onLoaded = { loaded -> - ShortsFeedLoaded( - loaded = loaded, - listState = listState, - accountViewModel = accountViewModel, - nav = nav, - ) - }, - ) - } + ) { + RefresheableBox(shortsFeedContentState, true) { + SaveableFeedContentState(shortsFeedContentState, scrollStateKey = ScrollStateKeys.SHORTS_SCREEN) { listState -> + RenderFeedContentState( + feedContentState = shortsFeedContentState, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = "ShortsFeed", + onLoaded = { loaded -> + ShortsFeedLoaded( + loaded = loaded, + listState = listState, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 679295e5db..f15309b043 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -85,6 +85,7 @@ import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor @@ -356,7 +357,7 @@ fun RenderThreadFeed( LazyColumn( modifier = Modifier.fillMaxSize(), - contentPadding = FeedPadding, + contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, ) { itemsIndexed( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadScreen.kt index 065d98839c..c301d1e88c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadScreen.kt @@ -20,11 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription @@ -72,8 +69,6 @@ fun ThreadScreen( }, accountViewModel = accountViewModel, ) { - Column(Modifier.padding(it)) { - ThreadFeedView(noteId, feedViewModel, accountViewModel, nav) - } + ThreadFeedView(noteId, feedViewModel, accountViewModel, nav) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt index 30ccc67f33..880e7b2ced 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt @@ -20,11 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed @@ -43,6 +40,7 @@ import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -97,16 +95,12 @@ fun VideoScreen( }, accountViewModel = accountViewModel, ) { - Column( - modifier = Modifier.padding(it).consumeWindowInsets(it), - ) { - RenderFeed( - videoFeedContentState = videoFeedContentState, - scrollKey = ScrollStateKeys.VIDEO_SCREEN, - accountViewModel = accountViewModel, - nav = nav, - ) - } + RenderFeed( + videoFeedContentState = videoFeedContentState, + scrollKey = ScrollStateKeys.VIDEO_SCREEN, + accountViewModel = accountViewModel, + nav = nav, + ) } } @@ -163,7 +157,7 @@ fun VideoFeedLoaded( val items by loaded.feed.collectAsStateWithLifecycle() LazyColumn( - contentPadding = FeedPadding, + contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, ) { itemsIndexed( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/WebBookmarksScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/WebBookmarksScreen.kt index 526e8b1fa0..1fee93c83a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/WebBookmarksScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/WebBookmarksScreen.kt @@ -25,7 +25,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -81,6 +80,7 @@ import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon @@ -155,18 +155,16 @@ private fun RenderWebBookmarksScreen( }, accountViewModel = accountViewModel, ) { - Column(Modifier.padding(it).fillMaxHeight()) { - RefresheableBox(feedState) { - SaveableFeedState(feedState, ScrollStateKeys.WEB_BOOKMARKS) { listState -> - RenderFeedContentState( - feedContentState = feedState, - accountViewModel = accountViewModel, - listState = listState, - nav = nav, - routeForLastRead = null, - onLoaded = { WebBookmarksFeedLoaded(it, listState, accountViewModel, nav) }, - ) - } + RefresheableBox(feedState) { + SaveableFeedState(feedState, ScrollStateKeys.WEB_BOOKMARKS) { listState -> + RenderFeedContentState( + feedContentState = feedState, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = null, + onLoaded = { WebBookmarksFeedLoaded(it, listState, accountViewModel, nav) }, + ) } } } @@ -182,7 +180,7 @@ private fun WebBookmarksFeedLoaded( val items by loaded.feed.collectAsStateWithLifecycle() LazyColumn( - contentPadding = FeedPadding, + contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, ) { itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item -> diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScrollTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScrollTest.kt new file mode 100644 index 0000000000..791c54b798 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScrollTest.kt @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.layouts + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.unit.Velocity +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +class DisappearingBarNestedScrollTest { + private fun state( + topLimit: Float = 100f, + bottomLimit: Float = 50f, + ) = DisappearingBarState().apply { + topHeightLimit = topLimit + bottomHeightLimit = bottomLimit + } + + private fun nsc( + state: DisappearingBarState, + canScroll: Boolean = true, + reverseLayout: Boolean = false, + ) = DisappearingBarNestedScroll( + state = state, + canScroll = { canScroll }, + reverseLayout = reverseLayout, + ) + + @Test + fun `onPostScroll does not consume any delta`() { + val state = state() + val connection = nsc(state) + + val consumed = connection.onPostScroll(Offset(0f, -20f), Offset(0f, 0f), NestedScrollSource.UserInput) + + assertEquals(Offset.Zero, consumed) + } + + @Test + fun `scrolling content up hides both bars by the total delta`() { + val state = state(topLimit = 100f, bottomLimit = 50f) + val connection = nsc(state) + + connection.onPostScroll(Offset(0f, -30f), Offset(0f, 0f), NestedScrollSource.UserInput) + + assertEquals(-30f, state.topHeightOffset) + assertEquals(-30f, state.bottomHeightOffset) + } + + @Test + fun `bars clamp at their individual limits`() { + val state = state(topLimit = 100f, bottomLimit = 50f) + val connection = nsc(state) + + connection.onPostScroll(Offset(0f, -200f), Offset(0f, 0f), NestedScrollSource.UserInput) + + assertEquals(-100f, state.topHeightOffset) + assertEquals(-50f, state.bottomHeightOffset) + } + + @Test + fun `scrolling content down reveals both bars from a hidden state`() { + val state = state(topLimit = 100f, bottomLimit = 50f) + state.topHeightOffset = -100f + state.bottomHeightOffset = -50f + val connection = nsc(state) + + connection.onPostScroll(Offset(0f, 30f), Offset(0f, 0f), NestedScrollSource.UserInput) + + assertEquals(-70f, state.topHeightOffset) + assertEquals(-20f, state.bottomHeightOffset) + } + + @Test + fun `uses consumed plus available so the bars see the whole scroll attempt`() { + val state = state() + state.topHeightOffset = -50f + state.bottomHeightOffset = -50f + val connection = nsc(state) + + // The list consumed 20px of a 40px reveal drag; 20 more was left as overscroll. + // The bars should move by the total 40, not just one of the halves. + connection.onPostScroll(Offset(0f, 20f), Offset(0f, 20f), NestedScrollSource.UserInput) + + assertEquals(-10f, state.topHeightOffset) + assertEquals(-10f, state.bottomHeightOffset) + } + + @Test + fun `canScroll returning false freezes the bars`() { + val state = state() + val connection = nsc(state, canScroll = false) + + connection.onPostScroll(Offset(0f, -100f), Offset(0f, 0f), NestedScrollSource.UserInput) + + assertEquals(0f, state.topHeightOffset) + assertEquals(0f, state.bottomHeightOffset) + } + + @Test + fun `reverseLayout inverts the delta sign`() { + val state = state() + val connection = nsc(state, reverseLayout = true) + + // In reverse layout, a positive Y is a "hide" direction + connection.onPostScroll(Offset(0f, 20f), Offset(0f, 0f), NestedScrollSource.UserInput) + + assertEquals(-20f, state.topHeightOffset) + assertEquals(-20f, state.bottomHeightOffset) + } + + @Test + fun `bar offsets never exceed zero when revealing`() { + val state = state() + // Start from a mid-hidden position + state.topHeightOffset = -10f + state.bottomHeightOffset = -10f + val connection = nsc(state) + + connection.onPostScroll(Offset(0f, 100f), Offset(0f, 0f), NestedScrollSource.UserInput) + + assertEquals(0f, state.topHeightOffset) + assertEquals(0f, state.bottomHeightOffset) + } + + @Test + fun `setting topHeightLimit smaller than current offset clamps it in`() { + val state = state(topLimit = 100f) + state.topHeightOffset = -80f + + state.topHeightLimit = 40f + + assertEquals(-40f, state.topHeightOffset) + } + + @Test + fun `onPostFling swallows all velocity so parents don't get a phantom fling`() = + runTest { + val state = state() + val connection = nsc(state) + + val remaining = + connection.onPostFling( + consumed = Velocity(0f, 1000f), + available = Velocity(0f, 200f), + ) + + assertEquals(Velocity.Zero, remaining) + } +}