mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 16:14:40 +00:00
Merge pull request #2455 from vitorpamplona/claude/fix-scaffold-scrolling-NHdRa
Refactor DisappearingScaffold to use unified bar state management
This commit is contained in:
+2
-1
@@ -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(
|
||||
|
||||
+80
@@ -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() }
|
||||
-171
@@ -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)
|
||||
}
|
||||
-62
@@ -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,
|
||||
)
|
||||
}
|
||||
+192
-45
@@ -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)
|
||||
+2
@@ -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 ->
|
||||
|
||||
+2
-1
@@ -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(
|
||||
|
||||
+18
-23
@@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-15
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-18
@@ -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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -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,
|
||||
|
||||
+25
-18
@@ -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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -98,6 +98,7 @@ fun MarmotGroupChatScreen(
|
||||
)
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
allowBarHide = false,
|
||||
) {
|
||||
Column(Modifier.padding(it).consumeWindowInsets(it).statusBarsPadding()) {
|
||||
MarmotGroupChatView(
|
||||
|
||||
+1
@@ -51,6 +51,7 @@ fun ChatroomByAuthorScreen(
|
||||
RoomByAuthorTopBar(authorPubKeyHex, accountViewModel, nav)
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
allowBarHide = false,
|
||||
) {
|
||||
Column(Modifier.padding(it)) {
|
||||
ChatroomByAuthor(authorPubKeyHex, draftMessage, accountViewModel, nav)
|
||||
|
||||
+1
@@ -82,6 +82,7 @@ fun ChatroomScreen(
|
||||
)
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
allowBarHide = false,
|
||||
) {
|
||||
Column(Modifier.padding(it).consumeWindowInsets(it).statusBarsPadding()) {
|
||||
ChatroomView(
|
||||
|
||||
+1
@@ -55,6 +55,7 @@ fun EphemeralChatScreen(
|
||||
}
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
allowBarHide = false,
|
||||
) {
|
||||
Column(Modifier.padding(it)) {
|
||||
EphemeralChatChannelView(channelId, draft, replyTo, accountViewModel, nav)
|
||||
|
||||
+1
@@ -53,6 +53,7 @@ fun PublicChatChannelScreen(
|
||||
}
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
allowBarHide = false,
|
||||
) {
|
||||
Column(Modifier.padding(it)) {
|
||||
PublicChatChannelView(channelId, draft, replyTo, accountViewModel, nav)
|
||||
|
||||
+1
@@ -61,6 +61,7 @@ fun LiveActivityChannelScreen(
|
||||
}
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
allowBarHide = false,
|
||||
) {
|
||||
Column(Modifier.padding(it)) {
|
||||
LiveActivityChannelView(channelId, draft, replyTo, accountViewModel, nav)
|
||||
|
||||
+2
-1
@@ -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(
|
||||
|
||||
+1
-5
@@ -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 =
|
||||
|
||||
-1
@@ -98,7 +98,6 @@ fun MessagesSinglePane(
|
||||
MessagesPager(
|
||||
pagerState,
|
||||
tabs,
|
||||
it,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
|
||||
-3
@@ -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,
|
||||
)
|
||||
|
||||
+1
-2
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-3
@@ -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) {
|
||||
|
||||
+5
-4
@@ -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 ->
|
||||
|
||||
+12
-16
@@ -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 ->
|
||||
|
||||
+27
-32
@@ -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()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -172,7 +172,6 @@ fun FollowPackFeedScreen(
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
HorizontalPager(
|
||||
contentPadding = it,
|
||||
state = pagerState,
|
||||
) { page ->
|
||||
when (page) {
|
||||
|
||||
+6
-10
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-10
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-10
@@ -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) {
|
||||
|
||||
+2
-19
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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(
|
||||
|
||||
+18
-23
@@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -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)
|
||||
}
|
||||
|
||||
+13
-12
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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(
|
||||
|
||||
+18
-23
@@ -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
-11
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-3
@@ -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 ->
|
||||
|
||||
+2
-1
@@ -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 ->
|
||||
|
||||
+9
-14
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-10
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+14
-13
@@ -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" },
|
||||
|
||||
+2
-1
@@ -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(
|
||||
|
||||
+18
-23
@@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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(
|
||||
|
||||
+1
-6
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+8
-14
@@ -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(
|
||||
|
||||
+12
-14
@@ -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 ->
|
||||
|
||||
+170
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user