1
0

chore: initial commit

Signed-off-by: Alan Brault <alan.brault@visus.io>
This commit is contained in:
2026-01-17 08:34:40 -05:00
commit f35f4f7983
77 changed files with 2664 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View File

@@ -0,0 +1,158 @@
package io.visus.orbis.core.ui
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
val Black: Color = Color(0xFF000000)
val Gray900: Color = Color(0xFF282828)
val Gray800: Color = Color(0xFF4b4b4b)
val Gray700: Color = Color(0xFF5e5e5e)
val Gray600: Color = Color(0xFF727272)
val Gray500: Color = Color(0xFF868686)
val Gray400: Color = Color(0xFFC7C7C7)
val Gray300: Color = Color(0xFFDFDFDF)
val Gray200: Color = Color(0xFFE2E2E2)
val Gray100: Color = Color(0xFFF7F7F7)
val Gray50: Color = Color(0xFFFFFFFF)
val White: Color = Color(0xFFFFFFFF)
val Red900: Color = Color(0xFF520810)
val Red800: Color = Color(0xFF950f22)
val Red700: Color = Color(0xFFbb032a)
val Red600: Color = Color(0xFFde1135)
val Red500: Color = Color(0xFFf83446)
val Red400: Color = Color(0xFFfc7f79)
val Red300: Color = Color(0xFFffb2ab)
val Red200: Color = Color(0xFFffd2cd)
val Red100: Color = Color(0xFFffe1de)
val Red50: Color = Color(0xFFfff0ee)
val Blue900: Color = Color(0xFF276EF1)
val Blue800: Color = Color(0xFF3F7EF2)
val Blue700: Color = Color(0xFF578EF4)
val Blue600: Color = Color(0xFF6F9EF5)
val Blue500: Color = Color(0xFF87AEF7)
val Blue400: Color = Color(0xFF9FBFF8)
val Blue300: Color = Color(0xFFB7CEFA)
val Blue200: Color = Color(0xFFCFDEFB)
val Blue100: Color = Color(0xFFE7EEFD)
val Blue50: Color = Color(0xFFFFFFFF)
val Green950: Color = Color(0xFF0B4627)
val Green900: Color = Color(0xFF16643B)
val Green800: Color = Color(0xFF1A7544)
val Green700: Color = Color(0xFF178C4E)
val Green600: Color = Color(0xFF1DAF61)
val Green500: Color = Color(0xFF1FC16B)
val Green400: Color = Color(0xFF3EE089)
val Green300: Color = Color(0xFF84EBB4)
val Green200: Color = Color(0xFFC2F5DA)
val Green100: Color = Color(0xFFD0FBE9)
val Green50: Color = Color(0xFFE0FAEC)
@Immutable
data class Colors(
val primary: Color,
val onPrimary: Color,
val secondary: Color,
val onSecondary: Color,
val tertiary: Color,
val onTertiary: Color,
val error: Color,
val onError: Color,
val success: Color,
val onSuccess: Color,
val disabled: Color,
val onDisabled: Color,
val surface: Color,
val onSurface: Color,
val background: Color,
val onBackground: Color,
val outline: Color,
val transparent: Color = Color.Transparent,
val white: Color = White,
val black: Color = Black,
val text: Color,
val textSecondary: Color,
val textDisabled: Color,
val scrim: Color,
val elevation: Color,
)
internal val LightColors =
Colors(
primary = Black,
onPrimary = White,
secondary = Gray400,
onSecondary = Black,
tertiary = Blue900,
onTertiary = White,
surface = Gray200,
onSurface = Black,
error = Red600,
onError = White,
success = Green600,
onSuccess = White,
disabled = Gray100,
onDisabled = Gray500,
background = White,
onBackground = Black,
outline = Gray300,
transparent = Color.Transparent,
white = White,
black = Black,
text = Black,
textSecondary = Gray700,
textDisabled = Gray400,
scrim = Color.Black.copy(alpha = 0.32f),
elevation = Gray700,
)
internal val DarkColors =
Colors(
primary = White,
onPrimary = Black,
secondary = Gray400,
onSecondary = White,
tertiary = Blue300,
onTertiary = Black,
surface = Gray900,
onSurface = White,
error = Red400,
onError = Black,
success = Green700,
onSuccess = Black,
disabled = Gray700,
onDisabled = Gray500,
background = Black,
onBackground = White,
outline = Gray800,
transparent = Color.Transparent,
white = White,
black = Black,
text = White,
textSecondary = Gray300,
textDisabled = Gray600,
scrim = Color.Black.copy(alpha = 0.72f),
elevation = Gray200,
)
val LocalColors = staticCompositionLocalOf { LightColors }
val LocalContentColor = compositionLocalOf { Color.Black }
val LocalContentAlpha = compositionLocalOf { 1f }
fun Colors.contentColorFor(backgroundColor: Color): Color {
return when (backgroundColor) {
primary -> onPrimary
secondary -> onSecondary
tertiary -> onTertiary
surface -> onSurface
error -> onError
success -> onSuccess
disabled -> onDisabled
background -> onBackground
else -> Color.Unspecified
}
}

View File

@@ -0,0 +1,61 @@
package io.visus.orbis.core.ui
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import io.visus.orbis.core.ui.foundation.ripple
object OrbisTheme {
val colors: Colors
@ReadOnlyComposable @Composable
get() = LocalColors.current
val typography: Typography
@ReadOnlyComposable @Composable
get() = LocalTypography.current
}
@Composable
fun OrbisTheme(
isDarkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
val rippleIndication = ripple()
val selectionColors = rememberTextSelectionColors(LightColors)
val typography = provideTypography()
val colors = if (isDarkTheme) DarkColors else LightColors
CompositionLocalProvider(
LocalColors provides colors,
LocalTypography provides typography,
LocalIndication provides rippleIndication,
LocalTextSelectionColors provides selectionColors,
LocalContentColor provides colors.contentColorFor(colors.background),
LocalTextStyle provides typography.body1,
content = content,
)
}
@Composable
fun contentColorFor(color: Color): Color {
return OrbisTheme.colors.contentColorFor(color)
}
@Composable
internal fun rememberTextSelectionColors(colorScheme: Colors): TextSelectionColors {
val primaryColor = colorScheme.primary
return remember(primaryColor) {
TextSelectionColors(
handleColor = primaryColor,
backgroundColor = primaryColor.copy(alpha = TextSelectionBackgroundOpacity),
)
}
}
internal const val TextSelectionBackgroundOpacity = 0.4f

View File

@@ -0,0 +1,139 @@
package io.visus.orbis.core.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.structuralEqualityPolicy
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
@Composable
fun fontFamily() = FontFamily.Default
data class Typography(
val h1: TextStyle,
val h2: TextStyle,
val h3: TextStyle,
val h4: TextStyle,
val body1: TextStyle,
val body2: TextStyle,
val body3: TextStyle,
val label1: TextStyle,
val label2: TextStyle,
val label3: TextStyle,
val button: TextStyle,
val input: TextStyle,
)
private val defaultTypography =
Typography(
h1 =
TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
lineHeight = 32.sp,
letterSpacing = 0.sp,
),
h2 =
TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp,
),
h3 =
TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.sp,
),
h4 =
TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.sp,
),
body1 =
TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.sp,
),
body2 =
TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.15.sp,
),
body3 =
TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.15.sp,
),
label1 =
TextStyle(
fontWeight = FontWeight.W500,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
label2 =
TextStyle(
fontWeight = FontWeight.W500,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp,
),
label3 =
TextStyle(
fontWeight = FontWeight.W500,
fontSize = 10.sp,
lineHeight = 12.sp,
letterSpacing = 0.5.sp,
),
button =
TextStyle(
fontWeight = FontWeight.W500,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 1.sp,
),
input =
TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.sp,
),
)
@Composable
fun provideTypography(): Typography {
val fontFamily = fontFamily()
return defaultTypography.copy(
h1 = defaultTypography.h1.copy(fontFamily = fontFamily),
h2 = defaultTypography.h2.copy(fontFamily = fontFamily),
h3 = defaultTypography.h3.copy(fontFamily = fontFamily),
h4 = defaultTypography.h4.copy(fontFamily = fontFamily),
body1 = defaultTypography.body1.copy(fontFamily = fontFamily),
body2 = defaultTypography.body2.copy(fontFamily = fontFamily),
body3 = defaultTypography.body3.copy(fontFamily = fontFamily),
label1 = defaultTypography.label1.copy(fontFamily = fontFamily),
label2 = defaultTypography.label2.copy(fontFamily = fontFamily),
label3 = defaultTypography.label3.copy(fontFamily = fontFamily),
button = defaultTypography.button.copy(fontFamily = fontFamily),
input = defaultTypography.input.copy(fontFamily = fontFamily),
)
}
val LocalTypography = staticCompositionLocalOf { defaultTypography }
val LocalTextStyle = compositionLocalOf(structuralEqualityPolicy()) { TextStyle.Default }

View File

@@ -0,0 +1,265 @@
package io.visus.orbis.core.ui.components
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.offset
import io.visus.orbis.core.ui.OrbisTheme
import io.visus.orbis.core.ui.contentColorFor
import io.visus.orbis.core.ui.foundation.systemBarsForVisualComponents
import kotlin.jvm.JvmInline
@Composable
fun Scaffold(
modifier: Modifier = Modifier,
topBar: @Composable () -> Unit = {},
bottomBar: @Composable () -> Unit = {},
snackbarHost: @Composable () -> Unit = {},
floatingActionButton: @Composable () -> Unit = {},
floatingActionButtonPosition: FabPosition = FabPosition.End,
containerColor: Color = OrbisTheme.colors.background,
contentColor: Color = contentColorFor(containerColor),
contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets,
content: @Composable (PaddingValues) -> Unit,
) {
Surface(modifier = modifier, color = containerColor, contentColor = contentColor) {
ScaffoldLayout(
fabPosition = floatingActionButtonPosition,
topBar = topBar,
bottomBar = bottomBar,
content = content,
snackbar = snackbarHost,
contentWindowInsets = contentWindowInsets,
fab = floatingActionButton,
)
}
}
@Composable
private fun ScaffoldLayout(
fabPosition: FabPosition,
topBar: @Composable () -> Unit,
content: @Composable (PaddingValues) -> Unit,
snackbar: @Composable () -> Unit,
fab: @Composable () -> Unit,
contentWindowInsets: WindowInsets,
bottomBar: @Composable () -> Unit,
) {
SubcomposeLayout { constraints ->
val layoutWidth = constraints.maxWidth
val layoutHeight = constraints.maxHeight
val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)
layout(layoutWidth, layoutHeight) {
val topBarPlaceables =
subcompose(ScaffoldLayoutContent.TopBar, topBar).map {
it.measure(looseConstraints)
}
val topBarHeight = topBarPlaceables.maxByOrNull { it.height }?.height ?: 0
val snackbarPlaceables =
subcompose(ScaffoldLayoutContent.Snackbar, snackbar).map {
// respect only bottom and horizontal for snackbar and fab
val leftInset =
contentWindowInsets
.getLeft(this@SubcomposeLayout, layoutDirection)
val rightInset =
contentWindowInsets
.getRight(this@SubcomposeLayout, layoutDirection)
val bottomInset = contentWindowInsets.getBottom(this@SubcomposeLayout)
// offset the snackbar constraints by the insets values
it.measure(
looseConstraints.offset(
-leftInset - rightInset,
-bottomInset,
),
)
}
val snackbarHeight = snackbarPlaceables.maxByOrNull { it.height }?.height ?: 0
val snackbarWidth = snackbarPlaceables.maxByOrNull { it.width }?.width ?: 0
val fabPlaceables =
subcompose(ScaffoldLayoutContent.Fab, fab).mapNotNull { measurable ->
// respect only bottom and horizontal for snackbar and fab
val leftInset =
contentWindowInsets.getLeft(
this@SubcomposeLayout,
layoutDirection,
)
val rightInset =
contentWindowInsets.getRight(
this@SubcomposeLayout,
layoutDirection,
)
val bottomInset = contentWindowInsets.getBottom(this@SubcomposeLayout)
measurable.measure(
looseConstraints.offset(
-leftInset - rightInset,
-bottomInset,
),
)
.takeIf { it.height != 0 && it.width != 0 }
}
val fabPlacement =
if (fabPlaceables.isNotEmpty()) {
val fabWidth = fabPlaceables.maxByOrNull { it.width }!!.width
val fabHeight = fabPlaceables.maxByOrNull { it.height }!!.height
// FAB distance from the left of the layout, taking into account LTR / RTL
val fabLeftOffset =
if (fabPosition == FabPosition.End) {
if (layoutDirection == LayoutDirection.Ltr) {
layoutWidth - FabSpacing.roundToPx() - fabWidth
} else {
FabSpacing.roundToPx()
}
} else {
(layoutWidth - fabWidth) / 2
}
FabPlacement(
left = fabLeftOffset,
width = fabWidth,
height = fabHeight,
)
} else {
null
}
val bottomBarPlaceables =
subcompose(ScaffoldLayoutContent.BottomBar) {
CompositionLocalProvider(
LocalFabPlacement provides fabPlacement,
content = bottomBar,
)
}.map { it.measure(looseConstraints) }
val bottomBarHeight = bottomBarPlaceables.maxByOrNull { it.height }?.height
val fabOffsetFromBottom =
fabPlacement?.let {
if (bottomBarHeight == null) {
it.height + FabSpacing.roundToPx() +
contentWindowInsets.getBottom(this@SubcomposeLayout)
} else {
// Total height is the bottom bar height + the FAB height + the padding
// between the FAB and bottom bar
bottomBarHeight + it.height + FabSpacing.roundToPx()
}
}
val snackbarOffsetFromBottom =
if (snackbarHeight != 0) {
snackbarHeight +
(
fabOffsetFromBottom ?: bottomBarHeight
?: contentWindowInsets.getBottom(this@SubcomposeLayout)
)
} else {
0
}
val bodyContentPlaceables =
subcompose(ScaffoldLayoutContent.MainContent) {
val insets = contentWindowInsets.asPaddingValues(this@SubcomposeLayout)
val innerPadding =
PaddingValues(
top =
if (topBarPlaceables.isEmpty()) {
insets.calculateTopPadding()
} else {
topBarHeight.toDp()
},
bottom =
if (bottomBarPlaceables.isEmpty() || bottomBarHeight == null) {
insets.calculateBottomPadding()
} else {
bottomBarHeight.toDp()
},
start = insets.calculateStartPadding((this@SubcomposeLayout).layoutDirection),
end = insets.calculateEndPadding((this@SubcomposeLayout).layoutDirection),
)
content(innerPadding)
}.map { it.measure(looseConstraints) }
// Placing to control drawing order to match default elevation of each placeable
bodyContentPlaceables.forEach {
it.place(0, 0)
}
topBarPlaceables.forEach {
it.place(0, 0)
}
snackbarPlaceables.forEach {
it.place(
(layoutWidth - snackbarWidth) / 2 +
contentWindowInsets.getLeft(
this@SubcomposeLayout,
layoutDirection,
),
layoutHeight - snackbarOffsetFromBottom,
)
}
// The bottom bar is always at the bottom of the layout
bottomBarPlaceables.forEach {
it.place(0, layoutHeight - (bottomBarHeight ?: 0))
}
// Explicitly not using placeRelative here as `leftOffset` already accounts for RTL
fabPlacement?.let { placement ->
fabPlaceables.forEach {
it.place(placement.left, layoutHeight - fabOffsetFromBottom!!)
}
}
}
}
}
object ScaffoldDefaults {
val contentWindowInsets: WindowInsets
@Composable
get() = WindowInsets.systemBarsForVisualComponents
}
@JvmInline
value class FabPosition internal constructor(
@Suppress("unused") private val value: Int,
) {
companion object {
val Center = FabPosition(0)
val End = FabPosition(1)
}
override fun toString(): String {
return when (this) {
Center -> "FabPosition.Center"
else -> "FabPosition.End"
}
}
}
@Immutable
internal class FabPlacement(
val left: Int,
val width: Int,
val height: Int,
)
internal val LocalFabPlacement = staticCompositionLocalOf<FabPlacement?> { null }
private val FabSpacing = 16.dp
private enum class ScaffoldLayoutContent { TopBar, MainContent, Snackbar, Fab, BottomBar }

View File

@@ -0,0 +1,202 @@
package io.visus.orbis.core.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.visus.orbis.core.ui.OrbisTheme
import io.visus.orbis.core.ui.LocalContentColor
import io.visus.orbis.core.ui.contentColorFor
import io.visus.orbis.core.ui.foundation.ripple
@Composable
@NonRestartableComposable
fun Surface(
modifier: Modifier = Modifier,
shape: Shape = RectangleShape,
color: Color = OrbisTheme.colors.surface,
contentColor: Color = contentColorFor(color),
shadowElevation: Dp = 0.dp,
border: BorderStroke? = null,
content: @Composable () -> Unit,
) {
CompositionLocalProvider(
LocalContentColor provides contentColor,
) {
Box(
modifier =
modifier
.surface(
shape = shape,
backgroundColor = color,
border = border,
shadowElevation = shadowElevation,
)
.semantics(mergeDescendants = false) {
isTraversalGroup = true
}
.pointerInput(Unit) {},
propagateMinConstraints = true,
) {
content()
}
}
}
@Composable
@NonRestartableComposable
fun Surface(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
shape: Shape = RectangleShape,
color: Color = OrbisTheme.colors.background,
contentColor: Color = contentColorFor(color),
shadowElevation: Dp = 0.dp,
border: BorderStroke? = null,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
content: @Composable () -> Unit,
) {
CompositionLocalProvider(
LocalContentColor provides contentColor,
) {
Box(
modifier =
modifier
.surface(
shape = shape,
backgroundColor = color,
border = border,
shadowElevation = shadowElevation,
)
.clickable(
interactionSource = interactionSource,
indication = ripple(color = contentColor),
enabled = enabled,
onClick = onClick,
),
propagateMinConstraints = true,
) {
content()
}
}
}
@Composable
@NonRestartableComposable
fun Surface(
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
shape: Shape = RectangleShape,
color: Color = OrbisTheme.colors.background,
contentColor: Color = contentColorFor(color),
shadowElevation: Dp = 0.dp,
border: BorderStroke? = null,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
content: @Composable () -> Unit,
) {
CompositionLocalProvider(
LocalContentColor provides contentColor,
) {
Box(
modifier =
modifier
.surface(
shape = shape,
backgroundColor = color,
border = border,
shadowElevation = shadowElevation,
)
.selectable(
selected = selected,
interactionSource = interactionSource,
indication = ripple(),
enabled = enabled,
onClick = onClick,
),
propagateMinConstraints = true,
) {
content()
}
}
}
@Composable
@NonRestartableComposable
fun Surface(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
shape: Shape = RectangleShape,
color: Color = OrbisTheme.colors.background,
contentColor: Color = contentColorFor(color),
shadowElevation: Dp = 0.dp,
border: BorderStroke? = null,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
content: @Composable () -> Unit,
) {
CompositionLocalProvider(
LocalContentColor provides contentColor,
) {
Box(
modifier =
modifier
.surface(
shape = shape,
backgroundColor = color,
border = border,
shadowElevation = shadowElevation,
)
.toggleable(
value = checked,
interactionSource = interactionSource,
indication = ripple(),
enabled = enabled,
onValueChange = onCheckedChange,
),
propagateMinConstraints = true,
) {
content()
}
}
}
@Composable
private fun Modifier.surface(
shape: Shape,
backgroundColor: Color,
border: BorderStroke?,
shadowElevation: Dp,
) = this
.shadow(
ambientColor = OrbisTheme.colors.elevation,
spotColor = OrbisTheme.colors.elevation,
elevation = shadowElevation,
shape = shape,
clip = false,
)
.then(if (border != null) Modifier.border(border, shape) else Modifier)
.background(color = backgroundColor, shape = shape)
.clip(shape)

View File

@@ -0,0 +1,74 @@
package io.visus.orbis.core.ui.foundation
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.TweenSpec
import androidx.compose.foundation.interaction.DragInteraction
import androidx.compose.foundation.interaction.FocusInteraction
import androidx.compose.foundation.interaction.HoverInteraction
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.ui.unit.Dp
internal suspend fun Animatable<Dp, *>.animateElevation(
target: Dp,
from: Interaction? = null,
to: Interaction? = null,
) {
val spec =
when {
// Moving to a new state
to != null -> ElevationDefaults.incomingAnimationSpecForInteraction(to)
// Moving to default, from a previous state
from != null -> ElevationDefaults.outgoingAnimationSpecForInteraction(from)
// Loading the initial state, or moving back to the baseline state from a disabled /
// unknown state, so just snap to the final value.
else -> null
}
if (spec != null) animateTo(target, spec) else snapTo(target)
}
private object ElevationDefaults {
fun incomingAnimationSpecForInteraction(interaction: Interaction): AnimationSpec<Dp>? {
return when (interaction) {
is PressInteraction.Press -> DefaultIncomingSpec
is DragInteraction.Start -> DefaultIncomingSpec
is HoverInteraction.Enter -> DefaultIncomingSpec
is FocusInteraction.Focus -> DefaultIncomingSpec
else -> null
}
}
fun outgoingAnimationSpecForInteraction(interaction: Interaction): AnimationSpec<Dp>? {
return when (interaction) {
is PressInteraction.Press -> DefaultOutgoingSpec
is DragInteraction.Start -> DefaultOutgoingSpec
is HoverInteraction.Enter -> HoveredOutgoingSpec
is FocusInteraction.Focus -> DefaultOutgoingSpec
else -> null
}
}
}
private val OutgoingSpecEasing: Easing = CubicBezierEasing(0.40f, 0.00f, 0.60f, 1.00f)
private val DefaultIncomingSpec =
TweenSpec<Dp>(
durationMillis = 120,
easing = FastOutSlowInEasing,
)
private val DefaultOutgoingSpec =
TweenSpec<Dp>(
durationMillis = 150,
easing = OutgoingSpecEasing,
)
private val HoveredOutgoingSpec =
TweenSpec<Dp>(
durationMillis = 120,
easing = OutgoingSpecEasing,
)

View File

@@ -0,0 +1,216 @@
package io.visus.orbis.core.ui.foundation
import androidx.compose.foundation.IndicationNodeFactory
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.material.ripple.RippleAlpha
import androidx.compose.material.ripple.createRippleModifierNode
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.Stable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorProducer
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
import androidx.compose.ui.node.DelegatableNode
import androidx.compose.ui.node.DelegatingNode
import androidx.compose.ui.node.ObserverModifierNode
import androidx.compose.ui.node.currentValueOf
import androidx.compose.ui.node.observeReads
import androidx.compose.ui.unit.Dp
import io.visus.orbis.core.ui.LocalContentColor
@Stable
fun ripple(
bounded: Boolean = true,
radius: Dp = Dp.Unspecified,
color: Color = Color.Unspecified,
): IndicationNodeFactory {
return if (radius == Dp.Unspecified && color == Color.Unspecified) {
if (bounded) return DefaultBoundedRipple else DefaultUnboundedRipple
} else {
RippleNodeFactory(bounded, radius, color)
}
}
@Stable
fun ripple(
color: ColorProducer,
bounded: Boolean = true,
radius: Dp = Dp.Unspecified,
): IndicationNodeFactory {
return RippleNodeFactory(bounded, radius, color)
}
/** Default values used by [ripple]. */
object RippleDefaults {
/**
* Represents the default [RippleAlpha] that will be used for a ripple to indicate different
* states.
*/
val RippleAlpha: RippleAlpha =
RippleAlpha(
pressedAlpha = StateTokens.PressedStateLayerOpacity,
focusedAlpha = StateTokens.FocusStateLayerOpacity,
draggedAlpha = StateTokens.DraggedStateLayerOpacity,
hoveredAlpha = StateTokens.HoverStateLayerOpacity,
)
}
val LocalRippleConfiguration: ProvidableCompositionLocal<RippleConfiguration?> =
compositionLocalOf {
RippleConfiguration()
}
@Immutable
class RippleConfiguration(
val color: Color = Color.Unspecified,
val rippleAlpha: RippleAlpha? = null,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is RippleConfiguration) return false
if (color != other.color) return false
if (rippleAlpha != other.rippleAlpha) return false
return true
}
override fun hashCode(): Int {
var result = color.hashCode()
result = 31 * result + (rippleAlpha?.hashCode() ?: 0)
return result
}
override fun toString(): String {
return "RippleConfiguration(color=$color, rippleAlpha=$rippleAlpha)"
}
}
@Stable
private class RippleNodeFactory
private constructor(
private val bounded: Boolean,
private val radius: Dp,
private val colorProducer: ColorProducer?,
private val color: Color,
) : IndicationNodeFactory {
constructor(
bounded: Boolean,
radius: Dp,
colorProducer: ColorProducer,
) : this(bounded, radius, colorProducer, Color.Unspecified)
constructor(bounded: Boolean, radius: Dp, color: Color) : this(bounded, radius, null, color)
override fun create(interactionSource: InteractionSource): DelegatableNode {
val colorProducer = colorProducer ?: ColorProducer { color }
return DelegatingThemeAwareRippleNode(interactionSource, bounded, radius, colorProducer)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is RippleNodeFactory) return false
if (bounded != other.bounded) return false
if (radius != other.radius) return false
if (colorProducer != other.colorProducer) return false
return color == other.color
}
override fun hashCode(): Int {
var result = bounded.hashCode()
result = 31 * result + radius.hashCode()
result = 31 * result + colorProducer.hashCode()
result = 31 * result + color.hashCode()
return result
}
}
private class DelegatingThemeAwareRippleNode(
private val interactionSource: InteractionSource,
private val bounded: Boolean,
private val radius: Dp,
private val color: ColorProducer,
) : DelegatingNode(), CompositionLocalConsumerModifierNode, ObserverModifierNode {
private var rippleNode: DelegatableNode? = null
override fun onAttach() {
updateConfiguration()
}
override fun onObservedReadsChanged() {
updateConfiguration()
}
/**
* Handles [LocalRippleConfiguration] changing between null / non-null. Changes to
* [RippleConfiguration.color] and [RippleConfiguration.rippleAlpha] are handled as part of the
* ripple definition.
*/
private fun updateConfiguration() {
observeReads {
val configuration = currentValueOf(LocalRippleConfiguration)
if (configuration == null) {
removeRipple()
} else {
if (rippleNode == null) attachNewRipple()
}
}
}
private fun attachNewRipple() {
val calculateColor =
ColorProducer {
val userDefinedColor = color()
if (userDefinedColor.isSpecified) {
userDefinedColor
} else {
// If this is null, the ripple will be removed, so this should always be non-null in
// normal use
val rippleConfiguration = currentValueOf(LocalRippleConfiguration)
if (rippleConfiguration?.color?.isSpecified == true) {
rippleConfiguration.color
} else {
currentValueOf(LocalContentColor)
}
}
}
val calculateRippleAlpha = {
// If this is null, the ripple will be removed, so this should always be non-null in
// normal use
val rippleConfiguration = currentValueOf(LocalRippleConfiguration)
rippleConfiguration?.rippleAlpha ?: RippleDefaults.RippleAlpha
}
rippleNode =
delegate(
createRippleModifierNode(
interactionSource,
bounded,
radius,
calculateColor,
calculateRippleAlpha,
),
)
}
private fun removeRipple() {
rippleNode?.let { undelegate(it) }
rippleNode = null
}
}
private object StateTokens {
const val DraggedStateLayerOpacity = 0.16f
const val FocusStateLayerOpacity = 0.1f
const val HoverStateLayerOpacity = 0.08f
const val PressedStateLayerOpacity = 0.1f
}
private val DefaultBoundedRipple =
RippleNodeFactory(bounded = true, radius = Dp.Unspecified, color = Color.Unspecified)
private val DefaultUnboundedRipple =
RippleNodeFactory(bounded = false, radius = Dp.Unspecified, color = Color.Unspecified)

View File

@@ -0,0 +1,10 @@
package io.visus.orbis.core.ui.foundation
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.systemBars
import androidx.compose.runtime.Composable
val WindowInsets.Companion.systemBarsForVisualComponents: WindowInsets
@Composable
get() = systemBars