mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
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:
+151
@@ -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)
|
||||
}
|
||||
}
|
||||
+79
@@ -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)
|
||||
}
|
||||
}
|
||||
+38
@@ -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>()
|
||||
}
|
||||
+104
@@ -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)
|
||||
}
|
||||
}
|
||||
+98
@@ -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,
|
||||
)
|
||||
}
|
||||
+38
@@ -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?,
|
||||
)
|
||||
+108
@@ -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)
|
||||
}
|
||||
+117
@@ -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
|
||||
}
|
||||
}
|
||||
+208
@@ -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() }
|
||||
+67
@@ -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)
|
||||
Reference in New Issue
Block a user