Merge pull request #2455 from vitorpamplona/claude/fix-scaffold-scrolling-NHdRa

Refactor DisappearingScaffold to use unified bar state management
This commit is contained in:
Vitor Pamplona
2026-04-20 10:42:01 -04:00
committed by GitHub
56 changed files with 986 additions and 939 deletions
@@ -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,
) {
@@ -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(
@@ -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)
}
}
@@ -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<Float>(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMediumLow,
)
val Saver: Saver<DisappearingBarState, *> =
Saver(
save = { listOf(it.topHeightOffset, it.bottomHeightOffset) },
restore = { DisappearingBarState(it[0], it[1]) },
)
}
}
@Composable
fun rememberDisappearingBarState(): DisappearingBarState = rememberSaveable(saver = DisappearingBarState.Saver) { DisappearingBarState() }
@@ -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<Float>?,
snapAnimationSpec: AnimationSpec<Float>?,
): 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)
}
@@ -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,
)
}
@@ -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) }
}
}
@@ -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<Float>?,
snapAnimationSpec: AnimationSpec<Float>?,
): 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<Float>(
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<Float>? = defaultMaterial3StandardSnap,
flingAnimationSpec: DecayAnimationSpec<Float>? = 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<Float>?,
override val flingAnimationSpec: DecayAnimationSpec<Float>?,
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)
}
}
}
@@ -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)
@@ -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),
@@ -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 ->
@@ -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(
@@ -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,
)
},
)
}
}
}
@@ -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",
)
}
}
}
@@ -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()),
)
}
}
}
}
@@ -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,
@@ -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()),
)
}
}
}
}
@@ -98,6 +98,7 @@ fun MarmotGroupChatScreen(
)
},
accountViewModel = accountViewModel,
allowBarHide = false,
) {
Column(Modifier.padding(it).consumeWindowInsets(it).statusBarsPadding()) {
MarmotGroupChatView(
@@ -51,6 +51,7 @@ fun ChatroomByAuthorScreen(
RoomByAuthorTopBar(authorPubKeyHex, accountViewModel, nav)
},
accountViewModel = accountViewModel,
allowBarHide = false,
) {
Column(Modifier.padding(it)) {
ChatroomByAuthor(authorPubKeyHex, draftMessage, accountViewModel, nav)
@@ -82,6 +82,7 @@ fun ChatroomScreen(
)
},
accountViewModel = accountViewModel,
allowBarHide = false,
) {
Column(Modifier.padding(it).consumeWindowInsets(it).statusBarsPadding()) {
ChatroomView(
@@ -55,6 +55,7 @@ fun EphemeralChatScreen(
}
},
accountViewModel = accountViewModel,
allowBarHide = false,
) {
Column(Modifier.padding(it)) {
EphemeralChatChannelView(channelId, draft, replyTo, accountViewModel, nav)
@@ -53,6 +53,7 @@ fun PublicChatChannelScreen(
}
},
accountViewModel = accountViewModel,
allowBarHide = false,
) {
Column(Modifier.padding(it)) {
PublicChatChannelView(channelId, draft, replyTo, accountViewModel, nav)
@@ -61,6 +61,7 @@ fun LiveActivityChannelScreen(
}
},
accountViewModel = accountViewModel,
allowBarHide = false,
) {
Column(Modifier.padding(it)) {
LiveActivityChannelView(channelId, draft, replyTo, accountViewModel, nav)
@@ -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(
@@ -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<MessagesTabItem>,
paddingValues: PaddingValues,
accountViewModel: AccountViewModel,
nav: INav,
) {
HorizontalPager(
contentPadding = paddingValues,
state = pagerState,
userScrollEnabled = true,
modifier =
@@ -98,7 +98,6 @@ fun MessagesSinglePane(
MessagesPager(
pagerState,
tabs,
it,
accountViewModel,
nav,
)
@@ -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,
)
@@ -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(),
)
}
}
@@ -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) {
@@ -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 ->
@@ -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 ->
@@ -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()
}
},
)
}
}
@@ -172,7 +172,6 @@ fun FollowPackFeedScreen(
accountViewModel = accountViewModel,
) {
HorizontalPager(
contentPadding = it,
state = pagerState,
) { page ->
when (page) {
@@ -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,
)
}
}
@@ -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,
)
}
}
@@ -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) {
@@ -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())
}
}
}
@@ -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(
@@ -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,
)
},
)
}
}
}
@@ -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)
}
@@ -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) },
)
}
}
}
@@ -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(
@@ -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,
)
},
)
}
}
}
@@ -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,
)
}
}
}
@@ -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 ->
@@ -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 ->
@@ -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,
)
}
}
}
@@ -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,
)
}
}
@@ -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" },
@@ -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(
@@ -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,
)
},
)
}
}
}
@@ -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(
@@ -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)
}
}
@@ -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(
@@ -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 ->
@@ -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)
}
}