Merge pull request #3394 from vitorpamplona/claude/composable-memory-ci-build-jxpwkv

Move UI components to commons module for better code sharing
This commit is contained in:
Vitor Pamplona
2026-06-26 20:12:09 -04:00
committed by GitHub
112 changed files with 122 additions and 101 deletions
@@ -0,0 +1,151 @@
/*
* 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.commons.ui.components
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.AnnotatedString.Builder
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withLink
@Composable
fun ClickableTextPrimary(
text: String,
modifier: Modifier = Modifier,
style: TextStyle = LocalTextStyle.current,
softWrap: Boolean = true,
overflow: TextOverflow = TextOverflow.Ellipsis,
maxLines: Int = Int.MAX_VALUE,
onClick: () -> Unit,
) {
ClickableTextColor(
text,
modifier,
style,
softWrap,
overflow,
maxLines,
MaterialTheme.colorScheme.primary,
onClick,
)
}
@Composable
fun ClickableTextColor(
text: String,
modifier: Modifier = Modifier,
style: TextStyle = LocalTextStyle.current,
softWrap: Boolean = true,
overflow: TextOverflow = TextOverflow.Ellipsis,
maxLines: Int = Int.MAX_VALUE,
linkColor: Color = MaterialTheme.colorScheme.primary,
onClick: () -> Unit,
) {
Text(
text =
remember(text) {
buildAnnotatedString {
appendLink(text, linkColor, onClick)
}
},
modifier = modifier,
style = style,
softWrap = softWrap,
overflow = overflow,
maxLines = maxLines,
)
}
@Composable
fun ClickableTextNormal(
text: String,
modifier: Modifier = Modifier,
style: TextStyle = LocalTextStyle.current,
softWrap: Boolean = true,
overflow: TextOverflow = TextOverflow.Ellipsis,
maxLines: Int = Int.MAX_VALUE,
onClick: () -> Unit,
) {
Text(
text =
remember(text) {
buildAnnotatedString {
appendLink(text, onClick)
}
},
modifier = modifier,
style = style,
softWrap = softWrap,
overflow = overflow,
maxLines = maxLines,
)
}
inline fun Builder.appendLink(
text: String,
color: Color,
crossinline onClick: () -> Unit,
) = withLink(
LinkAnnotation.Clickable(
"clickable",
TextLinkStyles(SpanStyle(color)),
) {
onClick()
},
) {
append(text)
}
inline fun Builder.appendLink(
text: String,
crossinline onClick: () -> Unit,
) = withLink(
LinkAnnotation.Clickable("clickable") {
onClick()
},
) {
append(text)
}
inline fun buildLinkString(
text: String,
crossinline onClick: () -> Unit,
): AnnotatedString =
buildAnnotatedString {
withLink(
LinkAnnotation.Clickable("link") {
onClick()
},
) {
append(text)
}
}
@@ -0,0 +1,79 @@
/*
* 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.commons.ui.components
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.DefaultAlpha
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.painter.Painter
/**
* Create and return a new [Painter] that wraps [painter] with its [alpha], [colorFilter], or [onDraw] overwritten.
*/
fun forwardingPainter(
painter: Painter,
alpha: Float = DefaultAlpha,
colorFilter: ColorFilter? = null,
onDraw: DrawScope.(ForwardingDrawInfo) -> Unit = DefaultOnDraw,
): Painter = ForwardingPainter(painter, alpha, colorFilter, onDraw)
data class ForwardingDrawInfo(
val painter: Painter,
val alpha: Float,
val colorFilter: ColorFilter?,
)
private class ForwardingPainter(
private val painter: Painter,
private var alpha: Float,
private var colorFilter: ColorFilter?,
private val onDraw: DrawScope.(ForwardingDrawInfo) -> Unit,
) : Painter() {
private var info = newInfo()
override val intrinsicSize get() = painter.intrinsicSize
override fun applyAlpha(alpha: Float): Boolean {
if (alpha != DefaultAlpha) {
this.alpha = alpha
this.info = newInfo()
}
return true
}
override fun applyColorFilter(colorFilter: ColorFilter?): Boolean {
if (colorFilter == null) {
this.colorFilter = colorFilter
this.info = newInfo()
}
return true
}
override fun DrawScope.onDraw() = onDraw(info)
private fun newInfo() = ForwardingDrawInfo(painter, alpha, colorFilter)
}
private val DefaultOnDraw: DrawScope.(ForwardingDrawInfo) -> Unit = { info ->
with(info.painter) {
draw(size, info.alpha, info.colorFilter)
}
}
@@ -0,0 +1,38 @@
/*
* 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.commons.ui.components
import androidx.compose.runtime.Immutable
@Immutable
sealed class GenericLoadable<T> {
@Immutable class Loading<T> : GenericLoadable<T>()
@Immutable class Loaded<T>(
val loaded: T,
) : GenericLoadable<T>()
@Immutable class Empty<T> : GenericLoadable<T>()
@Immutable class Error<T>(
val errorMessage: String,
) : GenericLoadable<T>()
}
@@ -0,0 +1,104 @@
/*
* 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.commons.ui.components
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
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.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun AnimatedBorderTextCornerRadius(
text: String,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
textAlign: TextAlign? = null,
fontSize: TextUnit = 12.sp,
) {
val infiniteTransition = rememberInfiniteTransition()
val animatedFloatRestart =
infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 100f,
animationSpec =
infiniteRepeatable(
animation = tween(5000, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
)
Text(
text = text,
fontSize = fontSize,
modifier =
modifier
.drawBehind {
val brush =
Brush.sweepGradient(
colors = listOf(Color.Cyan, Color.Magenta, Color.Yellow),
)
drawRoundRect(
brush = brush,
style =
Stroke(
width = 2.dp.toPx(),
cap = StrokeCap.Round,
join = StrokeJoin.Round,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), animatedFloatRestart.value),
),
cornerRadius =
androidx.compose.ui.geometry
.CornerRadius(6.dp.toPx()),
)
}.padding(3.dp),
color = color,
textAlign = textAlign,
)
}
// Example usage in a composable function:
@Composable
@Preview
fun ExampleAnimatedBorder() {
Column {
AnimatedBorderTextCornerRadius(text = "Rounded Corners", Modifier)
}
}
@@ -0,0 +1,98 @@
/*
* 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.commons.ui.components
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
val DefaultAnimationColors =
listOf(
Color(0xFF5851D8),
Color(0xFF833AB4),
Color(0xFFC13584),
Color(0xFFE1306C),
Color(0xFFFD1D1D),
Color(0xFFF56040),
Color(0xFFF77737),
Color(0xFFFCAF45),
Color(0xFFFFDC80),
Color(0xFF5851D8),
).toImmutableList()
@Composable
fun LoadingAnimation(
indicatorSize: Dp = 20.dp,
circleWidth: Dp = 4.dp,
circleColors: ImmutableList<Color> = DefaultAnimationColors,
animationDuration: Int = 1000,
) {
val infiniteTransition = rememberInfiniteTransition()
val rotateAnimation by
infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec =
infiniteRepeatable(
animation =
tween(
durationMillis = animationDuration,
easing = LinearEasing,
),
),
label = "UploadGalleryUploadingAnimation",
)
CircularProgressIndicator(
progress = { 1f },
modifier =
Modifier
.size(size = indicatorSize)
.rotate(degrees = rotateAnimation)
.border(
width = circleWidth,
brush = Brush.sweepGradient(circleColors),
shape = CircleShape,
),
color = MaterialTheme.colorScheme.background,
strokeWidth = 1.dp,
trackColor = ProgressIndicatorDefaults.circularDeterminateTrackColor,
)
}
@@ -0,0 +1,38 @@
/*
* 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.commons.ui.components
import androidx.compose.runtime.Immutable
/**
* The current translation state for a piece of content.
*
* `sourceLang` and `targetLang` are non-null only when an actual translation took place;
* a no-op (same language, undetected, blocklisted) keeps both null and `result` equal to the
* original content. The user-facing "show original" toggle is derived live from
* `AccountLanguagePreferences.preferenceBetween(...)` and is not stored here.
*/
@Immutable
data class TranslationConfig(
val result: String,
val sourceLang: String?,
val targetLang: String?,
)
@@ -0,0 +1,108 @@
/*
* 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.commons.ui.components
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.pager.PagerState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
private const val PAGER_ZONE_FRACTION = 0.5f
fun Modifier.zonedDrawerSwipe(
pagerState: PagerState,
openDrawer: () -> Unit,
): Modifier =
composed {
var widthPx by remember { mutableFloatStateOf(1f) }
var gestureStartX by remember { mutableFloatStateOf(0f) }
var gestureStartPage by remember { mutableIntStateOf(0) }
var drawerOpened by remember { mutableStateOf(false) }
val connection =
remember {
object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource,
): Offset {
if (source != NestedScrollSource.UserInput) return Offset.Zero
if (drawerOpened) return Offset(available.x, 0f)
// Non-first pages in the drawer zone: intercept before the
// pager consumes the delta to page backwards.
if (available.x > 0f) {
val wasOnFirstPage = gestureStartPage == 0
val isInPagerZone = gestureStartX < widthPx * PAGER_ZONE_FRACTION
if (!wasOnFirstPage && !isInPagerZone) {
drawerOpened = true
openDrawer()
return Offset(available.x, 0f)
}
}
return Offset.Zero
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset {
if (source != NestedScrollSource.UserInput) return Offset.Zero
if (drawerOpened) return Offset(available.x, 0f)
// First page: open drawer only with unconsumed right-swipe
// so child LazyRows can scroll first.
if (available.x > 0f && gestureStartPage == 0) {
drawerOpened = true
openDrawer()
return Offset(available.x, 0f)
}
return Offset.Zero
}
}
}
this
.onSizeChanged { widthPx = it.width.toFloat() }
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
gestureStartX = down.position.x
gestureStartPage = pagerState.currentPage
drawerOpened = false
}
}.nestedScroll(connection)
}
@@ -0,0 +1,117 @@
/*
* 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.commons.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
import kotlin.math.abs
/**
* 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.
* - Pure overscroll (`consumed.y == 0`) from the fully-visible state is ignored. This
* prevents short, non-scrollable lists from hiding the bars purely on an overscroll
* gesture — which would leave blank padding at the top and bottom. Once the bars have
* started moving (list is clearly scrollable), edge overscroll keeps affecting them.
* - 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.
* - Both hiding and revealing track the finger 1:1. The bar offset must equal the content's
* scroll offset so the bar's bottom edge stays glued to the first item's top edge; any
* asymmetry (e.g. a damped reveal) leaves the bar lagging behind the content and opens a
* blank band between the bar and the first item when the list returns to the top.
*
* Making the reveal a *deliberate* gesture — so the tiny reverse drag a finger makes when it
* catches/stops a fast scroll doesn't pop the chrome back — is handled without breaking that
* 1:1 invariant: a partial reveal that doesn't cross the halfway point is snapped back to the
* hidden edge by [DisappearingBarState.settleToNearestEdge] on fling/lift, and the binary OS
* status bar is debounced by the show/hide hysteresis in the scaffold.
*/
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
// Dead-zone: ignore sub-pixel jitter so the bars (and the binary status-bar toggle that
// tracks their collapse fraction) don't twitch on scroll noise. Kept symmetric and tiny so
// it never makes the reveal lag the content enough to open a visible gap.
if (abs(totalY) < MIN_SCROLL_DELTA) return Offset.Zero
// If the list did not consume any scroll and the bars are fully visible, treat
// this as a non-scrollable list and keep the bars in place. Without this, a tiny
// feed would hide its chrome purely from overscroll gestures, leaving two empty
// bands at the top and bottom where the bars used to be.
val isPureOverscroll = consumed.y == 0f
val barsFullyVisible = state.topHeightOffset == 0f && state.bottomHeightOffset == 0f
if (isPureOverscroll && barsFullyVisible) 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
// 1:1 in both directions: the bar offset mirrors the content scroll so the bar stays glued
// to the first item. Deliberate reveal is enforced on settle, not by damping the delta here.
state.topHeightOffset = (state.topHeightOffset + deltaY).coerceIn(-topLimit, 0f)
state.bottomHeightOffset = (state.bottomHeightOffset + deltaY).coerceIn(-bottomLimit, 0f)
}
companion object {
/**
* Sub-pixel dead-zone: scroll attempts smaller than this are ignored so jitter doesn't nudge
* the bars. Tiny on purpose — large enough to swallow fractional noise, small enough that the
* bar offset never measurably lags the content scroll.
*/
const val MIN_SCROLL_DELTA = 0.5f
}
}
@@ -0,0 +1,208 @@
/*
* 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.commons.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,
) {
private var _topHeightOffset by mutableFloatStateOf(initialTopHeightOffset)
private var _bottomHeightOffset by mutableFloatStateOf(initialBottomHeightOffset)
/**
* Latches that record whether each bar has been driven all the way to its hidden edge by real
* scrolling since it was last fully in view. The settle reads them to tell a deliberate hide —
* where the content has scrolled at least a full bar height, so snapping the bar fully hidden
* leaves content (not a blank band) in the slot it vacates — apart from a small near-top
* collapse, where snapping hidden would expose the background because the content hasn't
* scrolled far enough to fill the bar's slot.
*/
private var topReachedHiddenEdge = false
private var bottomReachedHiddenEdge = false
var topHeightOffset: Float
get() = _topHeightOffset
set(value) {
_topHeightOffset = value
updateLatch(value, topHeightLimit) { topReachedHiddenEdge = it }
}
var bottomHeightOffset: Float
get() = _bottomHeightOffset
set(value) {
_bottomHeightOffset = value
updateLatch(value, bottomHeightLimit) { bottomReachedHiddenEdge = it }
}
private inline fun updateLatch(
offset: Float,
limit: Float,
set: (Boolean) -> Unit,
) {
if (limit <= 0f) return
if (offset >= 0f) {
set(false)
} else if (offset <= -limit) {
set(true)
}
}
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, topReachedHiddenEdge, initialVelocityY) { topHeightOffset = it } }
launch { settleOne({ bottomHeightOffset }, bottomHeightLimit, bottomReachedHiddenEdge, 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, topHeightLimit) { topHeightOffset = it } }
launch { animateOne({ bottomHeightOffset }, 0f, 0f, bottomHeightLimit) { bottomHeightOffset = it } }
}
}
private suspend fun settleOne(
get: () -> Float,
limit: Float,
canFullyHide: Boolean,
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 {
// Snapping to the hidden edge is only safe once the bar has actually been scrolled
// there (canFullyHide): the content has then moved at least a full bar height and
// fills the slot the bar vacates. From a small near-top collapse it hasn't, so
// snapping hidden would open a blank band — settle back into view instead.
!canFullyHide -> 0f
initialVelocityY < -VELOCITY_BIAS_THRESHOLD -> -limit
initialVelocityY > VELOCITY_BIAS_THRESHOLD -> 0f
positionBiasToHide -> -limit
else -> 0f
}
animateOne(get, target, initialVelocityY, limit, set)
}
private suspend fun animateOne(
get: () -> Float,
target: Float,
initialVelocity: Float,
limit: Float,
set: (Float) -> Unit,
) {
val start = get()
if (start == target && initialVelocity == 0f) return
Animatable(start)
.apply {
// Clamp to the visible travel range. A critically-damped spring still crosses its
// target once when given an initial velocity in the target's direction, so without
// these bounds a fast reveal fling would push the offset past 0 (or past -limit on
// a hide) and render the bar overshooting its resting edge before springing back.
// Hitting a bound ends the animation at the edge — a crisp settle with no rebound.
if (limit > 0f) updateBounds(lowerBound = -limit, upperBound = 0f)
}.animateTo(
targetValue = target,
animationSpec = SETTLE_SPRING,
initialVelocity = initialVelocity,
) {
set(value)
}
}
companion object {
private const val VELOCITY_BIAS_THRESHOLD = 200f
// Bounce-free so the chrome never wobbles past its edge, but stiff enough to feel like a
// quick native snap rather than a slow float once the finger lifts. Overshoot from a strong
// initial velocity is still caught by the bounds in animateOne, so a higher stiffness here
// only affects how briskly the bar resolves to its edge.
private val SETTLE_SPRING =
spring<Float>(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMedium,
)
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() }
@@ -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.commons.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)