From bf2b9cbb392dff01db938792c83b02b869aeccfa Mon Sep 17 00:00:00 2001 From: Alan Brault Date: Sat, 17 Jan 2026 08:52:34 -0500 Subject: [PATCH] chore(core/ui): add components Signed-off-by: Alan Brault --- .../io/visus/orbis/core/ui/components/Icon.kt | 98 ++++++ .../orbis/core/ui/components/NavigationBar.kt | 318 ++++++++++++++++++ .../io/visus/orbis/core/ui/components/Text.kt | 180 ++++++++++ .../orbis/core/ui/foundation/Providers.kt | 28 ++ 4 files changed, 624 insertions(+) create mode 100644 core/ui/src/main/java/io/visus/orbis/core/ui/components/Icon.kt create mode 100644 core/ui/src/main/java/io/visus/orbis/core/ui/components/NavigationBar.kt create mode 100644 core/ui/src/main/java/io/visus/orbis/core/ui/components/Text.kt create mode 100644 core/ui/src/main/java/io/visus/orbis/core/ui/foundation/Providers.kt diff --git a/core/ui/src/main/java/io/visus/orbis/core/ui/components/Icon.kt b/core/ui/src/main/java/io/visus/orbis/core/ui/components/Icon.kt new file mode 100644 index 0000000..a669426 --- /dev/null +++ b/core/ui/src/main/java/io/visus/orbis/core/ui/components/Icon.kt @@ -0,0 +1,98 @@ +package io.visus.orbis.core.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.paint +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.toolingGraphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import io.visus.orbis.core.ui.LocalContentColor + +@Composable +fun Icon( + imageVector: ImageVector, + modifier: Modifier = Modifier, + contentDescription: String? = null, + tint: Color = LocalContentColor.current, +) { + Icon( + painter = rememberVectorPainter(imageVector), + contentDescription = contentDescription, + modifier = modifier, + tint = tint, + ) +} + +@Composable +fun Icon( + bitmap: ImageBitmap, + modifier: Modifier = Modifier, + contentDescription: String? = null, + tint: Color = LocalContentColor.current, +) { + val painter = remember(bitmap) { BitmapPainter(bitmap) } + Icon( + painter = painter, + contentDescription = contentDescription, + modifier = modifier, + tint = tint, + ) +} + +@Composable +fun Icon( + painter: Painter, + modifier: Modifier = Modifier, + contentDescription: String? = null, + tint: Color = LocalContentColor.current, +) { + val colorFilter = if (tint == Color.Unspecified) null else ColorFilter.tint(tint) + val semantics = + if (contentDescription != null) { + Modifier.semantics { + this.contentDescription = contentDescription + this.role = Role.Image + } + } else { + Modifier + } + Box( + modifier + .toolingGraphicsLayer() + .defaultSizeFor(painter) + .paint(painter, colorFilter = colorFilter, contentScale = ContentScale.Fit) + .then(semantics), + ) +} + +private fun Modifier.defaultSizeFor(painter: Painter) = + this.then( + if (painter.intrinsicSize == Size.Unspecified || painter.intrinsicSize.isInfinite()) { + DefaultIconSizeModifier + } else { + Modifier + }, + ) + +private fun Size.isInfinite() = width.isInfinite() && height.isInfinite() + +private val DefaultIconSizeModifier = Modifier.size(IconDefaults.iconSize) + +internal object IconDefaults { + val iconSize = 24.dp +} diff --git a/core/ui/src/main/java/io/visus/orbis/core/ui/components/NavigationBar.kt b/core/ui/src/main/java/io/visus/orbis/core/ui/components/NavigationBar.kt new file mode 100644 index 0000000..d660ec8 --- /dev/null +++ b/core/ui/src/main/java/io/visus/orbis/core/ui/components/NavigationBar.kt @@ -0,0 +1,318 @@ +package io.visus.orbis.core.ui.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Constraints +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.components.NavigationBarDefaults.NavigationBarHeight +import io.visus.orbis.core.ui.components.NavigationBarItemDefaults.ItemAnimationDurationMillis +import io.visus.orbis.core.ui.components.NavigationBarItemDefaults.NavigationBarItemHorizontalPadding +import io.visus.orbis.core.ui.components.NavigationBarItemDefaults.NavigationBarItemVerticalPadding +import io.visus.orbis.core.ui.contentColorFor +import io.visus.orbis.core.ui.foundation.ProvideTextStyle +import io.visus.orbis.core.ui.foundation.systemBarsForVisualComponents +import kotlin.math.roundToInt + +@Composable +fun NavigationBar( + modifier: Modifier = Modifier, + containerColor: Color = NavigationBarDefaults.containerColor, + contentColor: Color = contentColorFor(containerColor), + windowInsets: WindowInsets = NavigationBarDefaults.windowInsets, + content: @Composable RowScope.() -> Unit, +) { + Surface( + color = containerColor, + contentColor = contentColor, + modifier = modifier, + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .windowInsetsPadding(windowInsets) + .height(NavigationBarHeight) + .selectableGroup(), + content = content, + ) + } +} + +@Composable +fun RowScope.NavigationBarItem( + selected: Boolean, + onClick: () -> Unit, + icon: @Composable () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + label: @Composable (() -> Unit)? = null, + alwaysShowLabel: Boolean = true, + colors: NavigationBarItemColors = NavigationBarItemDefaults.colors(), + textStyle: TextStyle = NavigationBarItemDefaults.textStyle(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val styledIcon = @Composable { + val iconColor by colors.iconColor(selected = selected, enabled = enabled) + val clearSemantics = label != null && (alwaysShowLabel || selected) + Box(modifier = if (clearSemantics) Modifier.clearAndSetSemantics {} else Modifier) { + CompositionLocalProvider(LocalContentColor provides iconColor, content = icon) + } + } + + val styledLabel: @Composable (() -> Unit)? = + label?.let { + @Composable { + val textColor by colors.textColor(selected = selected, enabled = enabled) + CompositionLocalProvider(LocalContentColor provides textColor) { + ProvideTextStyle(textStyle, content = label) + } + } + } + + var itemWidth by remember { mutableIntStateOf(0) } + + Box( + modifier + .selectable( + selected = selected, + onClick = onClick, + enabled = enabled, + role = Role.Tab, + interactionSource = interactionSource, + indication = null, + ) + .semantics { + role = Role.Tab + } + .weight(1f) + .onSizeChanged { + itemWidth = it.width + }, + contentAlignment = Alignment.Center, + ) { + val animationProgress: Float by animateFloatAsState( + targetValue = if (selected) 1f else 0f, + animationSpec = tween(ItemAnimationDurationMillis), + ) + + NavigationBarItemBaselineLayout( + icon = styledIcon, + label = styledLabel, + alwaysShowLabel = alwaysShowLabel, + animationProgress = animationProgress, + ) + } +} + +@Composable +private fun NavigationBarItemBaselineLayout( + icon: @Composable () -> Unit, + label: @Composable (() -> Unit)?, + alwaysShowLabel: Boolean, + animationProgress: Float, +) { + Layout({ + Box(Modifier.layoutId(IconLayoutIdTag)) { icon() } + + if (label != null) { + Box( + Modifier + .layoutId(LabelLayoutIdTag) + .alpha(if (alwaysShowLabel) 1f else animationProgress) + .padding(horizontal = NavigationBarItemHorizontalPadding / 2), + ) { label() } + } + }) { measurables, constraints -> + val iconPlaceable = + measurables.first { it.layoutId == IconLayoutIdTag }.measure(constraints) + + val labelPlaceable = + label?.let { + measurables.first { it.layoutId == LabelLayoutIdTag }.measure( + constraints.copy(minHeight = 0), + ) + } + + if (label == null) { + placeIcon(iconPlaceable, constraints) + } else { + placeLabelAndIcon( + labelPlaceable!!, + iconPlaceable, + constraints, + alwaysShowLabel, + animationProgress, + ) + } + } +} + +private fun MeasureScope.placeIcon( + iconPlaceable: Placeable, + constraints: Constraints, +): MeasureResult { + val width = constraints.maxWidth + val height = constraints.maxHeight + + val iconX = (width - iconPlaceable.width) / 2 + val iconY = (height - iconPlaceable.height) / 2 + + return layout(width, height) { + iconPlaceable.placeRelative(iconX, iconY) + } +} + +private fun MeasureScope.placeLabelAndIcon( + labelPlaceable: Placeable, + iconPlaceable: Placeable, + constraints: Constraints, + alwaysShowLabel: Boolean, + animationProgress: Float, +): MeasureResult { + val height = constraints.maxHeight + + val labelY = + height - labelPlaceable.height - NavigationBarItemVerticalPadding.roundToPx() + + val selectedIconY = NavigationBarItemVerticalPadding.roundToPx() + val unselectedIconY = + if (alwaysShowLabel) selectedIconY else (height - iconPlaceable.height) / 2 + + val iconDistance = unselectedIconY - selectedIconY + + val offset = (iconDistance * (1 - animationProgress)).roundToInt() + + val containerWidth = constraints.maxWidth + + val labelX = (containerWidth - labelPlaceable.width) / 2 + val iconX = (containerWidth - iconPlaceable.width) / 2 + + return layout(containerWidth, height) { + if (alwaysShowLabel || animationProgress != 0f) { + labelPlaceable.placeRelative(labelX, labelY + offset) + } + iconPlaceable.placeRelative(iconX, selectedIconY + offset) + } +} + +internal object NavigationBarDefaults { + internal val NavigationBarHeight: Dp = 80.0.dp + val containerColor: Color @Composable get() = OrbisTheme.colors.background + + val windowInsets: WindowInsets + @Composable get() = + WindowInsets.systemBarsForVisualComponents.only( + WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom, + ) +} + +object NavigationBarItemDefaults { + internal val NavigationBarItemHorizontalPadding: Dp = 8.dp + internal val NavigationBarItemVerticalPadding: Dp = 18.dp + internal const val ItemAnimationDurationMillis: Int = 100 + + @Composable + fun colors( + selectedIconColor: Color = OrbisTheme.colors.onBackground, + selectedTextColor: Color = OrbisTheme.colors.onBackground, + unselectedIconColor: Color = OrbisTheme.colors.onBackground.copy(alpha = 0.65f), + unselectedTextColor: Color = OrbisTheme.colors.onBackground.copy(alpha = 0.65f), + disabledIconColor: Color = OrbisTheme.colors.onBackground.copy(alpha = 0.3f), + disabledTextColor: Color = OrbisTheme.colors.onBackground.copy(alpha = 0.3f), + ): NavigationBarItemColors = + NavigationBarItemColors( + selectedIconColor = selectedIconColor, + selectedTextColor = selectedTextColor, + unselectedIconColor = unselectedIconColor, + unselectedTextColor = unselectedTextColor, + disabledIconColor = disabledIconColor, + disabledTextColor = disabledTextColor, + ) + + @Composable + fun textStyle(): TextStyle = OrbisTheme.typography.label2 +} + +@ConsistentCopyVisibility +@Stable +data class NavigationBarItemColors internal constructor( + private val selectedIconColor: Color, + private val selectedTextColor: Color, + private val unselectedIconColor: Color, + private val unselectedTextColor: Color, + private val disabledIconColor: Color, + private val disabledTextColor: Color, +) { + @Composable + internal fun iconColor(selected: Boolean, enabled: Boolean): State { + val targetValue = + when { + !enabled -> disabledIconColor + selected -> selectedIconColor + else -> unselectedIconColor + } + return animateColorAsState( + targetValue = targetValue, + animationSpec = tween(NavigationBarItemDefaults.ItemAnimationDurationMillis), + label = "icon-color", + ) + } + + @Composable + internal fun textColor(selected: Boolean, enabled: Boolean): State { + val targetValue = + when { + !enabled -> disabledTextColor + selected -> selectedTextColor + else -> unselectedTextColor + } + return animateColorAsState( + targetValue = targetValue, + animationSpec = tween(NavigationBarItemDefaults.ItemAnimationDurationMillis), + label = "text-color", + ) + } +} + +private const val IconLayoutIdTag: String = "icon" +private const val LabelLayoutIdTag: String = "label" diff --git a/core/ui/src/main/java/io/visus/orbis/core/ui/components/Text.kt b/core/ui/src/main/java/io/visus/orbis/core/ui/components/Text.kt new file mode 100644 index 0000000..4192801 --- /dev/null +++ b/core/ui/src/main/java/io/visus/orbis/core/ui/components/Text.kt @@ -0,0 +1,180 @@ +package io.visus.orbis.core.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import io.visus.orbis.core.ui.LocalContentColor +import io.visus.orbis.core.ui.LocalTextStyle +import io.visus.orbis.core.ui.LocalTypography +import androidx.compose.ui.tooling.preview.Preview + +@Composable +fun Text( + text: String, + modifier: Modifier = Modifier, + color: Color = LocalContentColor.current, + fontSize: TextUnit = TextUnit.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign = TextAlign.Start, + lineHeight: TextUnit = TextUnit.Unspecified, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + minLines: Int = 1, + onTextLayout: (TextLayoutResult) -> Unit = {}, + style: TextStyle = LocalTextStyle.current, +) { + Text( + text = AnnotatedString(text = text), + modifier = modifier, + color = color, + fontSize = fontSize, + fontStyle = fontStyle, + fontWeight = fontWeight, + fontFamily = fontFamily, + letterSpacing = letterSpacing, + textDecoration = textDecoration, + textAlign = textAlign, + lineHeight = lineHeight, + overflow = overflow, + softWrap = softWrap, + maxLines = maxLines, + minLines = minLines, + onTextLayout = onTextLayout, + style = style, + ) +} + +@Composable +internal fun Text( + text: AnnotatedString, + modifier: Modifier = Modifier, + color: Color = LocalContentColor.current, + fontSize: TextUnit = TextUnit.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign = TextAlign.Start, + lineHeight: TextUnit = TextUnit.Unspecified, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + minLines: Int = 1, + inlineContent: Map = mapOf(), + onTextLayout: (TextLayoutResult) -> Unit = {}, + style: TextStyle = LocalTextStyle.current, +) { + val mergedStyle = + style.merge( + TextStyle( + color = color, + fontSize = fontSize, + fontWeight = fontWeight, + textAlign = textAlign, + lineHeight = lineHeight, + fontFamily = fontFamily, + textDecoration = textDecoration, + fontStyle = fontStyle, + letterSpacing = letterSpacing, + ), + ) + BasicText( + text, + modifier, + mergedStyle, + onTextLayout, + overflow, + softWrap, + maxLines, + minLines, + inlineContent, + ) +} + +@Preview +@Composable +fun TypographySample() { + val typography = LocalTypography.current + + Column( + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = "H1 Heading", + style = typography.h1, + ) + Text( + text = "H2 Heading", + style = typography.h2, + ) + Text( + text = "H3 Heading", + style = typography.h3, + ) + Text( + text = "H4 Heading", + style = typography.h4, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "This is body1 text.", + style = typography.body1, + ) + Text( + text = "This is body2 text.", + style = typography.body2, + ) + Text( + text = "Body3 text for fine print.", + style = typography.body3, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Label1: Form Label", + style = typography.label1, + ) + Text( + text = "Label2: Secondary Info", + style = typography.label2, + ) + Text( + text = "Label3: Tiny Details", + style = typography.label3, + ) + + Spacer(modifier = Modifier.height(16.dp)) + } +} diff --git a/core/ui/src/main/java/io/visus/orbis/core/ui/foundation/Providers.kt b/core/ui/src/main/java/io/visus/orbis/core/ui/foundation/Providers.kt new file mode 100644 index 0000000..e99a131 --- /dev/null +++ b/core/ui/src/main/java/io/visus/orbis/core/ui/foundation/Providers.kt @@ -0,0 +1,28 @@ +package io.visus.orbis.core.ui.foundation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import io.visus.orbis.core.ui.LocalContentColor +import io.visus.orbis.core.ui.LocalTextStyle + +@Composable +fun ProvideTextStyle(value: TextStyle, content: @Composable () -> Unit) { + val mergedStyle = LocalTextStyle.current.merge(value) + CompositionLocalProvider(LocalTextStyle provides mergedStyle, content = content) +} + +@Composable +internal fun ProvideContentColorTextStyle( + contentColor: Color, + textStyle: TextStyle, + content: @Composable () -> Unit, +) { + val mergedStyle = LocalTextStyle.current.merge(textStyle) + CompositionLocalProvider( + LocalContentColor provides contentColor, + LocalTextStyle provides mergedStyle, + content = content, + ) +}