1
0

feat(orb): implement orb animation

Signed-off-by: Alan Brault <alan.brault@visus.io>
This commit is contained in:
2026-01-21 11:05:33 -05:00
parent 063ec3b7a0
commit 3f6bd6c128
39 changed files with 2929 additions and 653 deletions

80
CLAUDE.md Normal file
View File

@@ -0,0 +1,80 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build Commands
```bash
# Build
./gradlew build # Full build
./gradlew assembleDebug # Debug APK
./gradlew assembleRelease # Release APK
# Test
./gradlew test # Run unit tests
./gradlew connectedAndroidTest # Run instrumented tests (requires device/emulator)
./gradlew :app:testDebugUnitTest # Run tests for specific module/variant
# Lint
./gradlew lint # Run Android Lint
./gradlew lintDebug # Lint specific variant
# Clean
./gradlew clean
```
## Architecture
This is a **Kotlin Android application** using Jetpack Compose and Material 3.
### Module Structure
- **`:app`** - Main Android application entry point
- **`:core:ui`** - Shared design system library with theming components
- **`:feature:orb`** - Orb feature with Compose UI and WebGPU rendering
### Dependency Injection (Koin)
Uses Koin 4.1.1 for dependency injection. Modules are defined in `app/src/main/java/io/visus/orbis/di/AppModule.kt`.
```kotlin
// Define dependencies using autowire DSL (constructor params resolved automatically)
val appModule = module {
singleOf(::MyRepository) // Singleton
factoryOf(::MyUseCase) // New instance each time
viewModelOf(::MyViewModel) // ViewModel scoped to lifecycle
}
// Inject in Android classes via delegate
class MyActivity : ComponentActivity() {
private val viewModel: MyViewModel by viewModel()
}
```
### Design System (core/ui)
The `core/ui` module provides a custom theme system (`OrbisTheme`) with:
- **Color.kt** - Color palette with light/dark mode support (grayscale, red, blue, green schemes with 10 variants each)
- **Typography.kt** - 12 text styles (h1-h4, body1-3, label1-3, button, input)
- **Theme.kt** - Composable theme wrapper that provides colors and typography via CompositionLocal
- **foundation/** - Elevation animations and custom ripple effects
### Theme Usage
```kotlin
OrbisTheme {
// Access theme values via:
OrbisTheme.colors.primary
OrbisTheme.typography.h1
}
```
## Key Configuration
- **Min SDK:** 24 | **Target SDK:** 36
- **Kotlin:** 2.3.0 with Compose plugin
- **Compose BOM:** 2026.01.00
- **Koin:** 4.1.1 (dependency injection)
- **Lumo Plugin:** 1.2.5 (theme/component generation, configured in `lumo.properties`)
- **Java Compatibility:** Java 17

View File

@@ -40,8 +40,6 @@ android {
dependencies { dependencies {
// Project modules // Project modules
implementation(project(":core:ui")) implementation(project(":core:ui"))
implementation(project(":core:domain"))
implementation(project(":core:data"))
implementation(project(":feature:orb")) implementation(project(":feature:orb"))
// AndroidX // AndroidX

View File

@@ -5,12 +5,12 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import io.visus.orbis.core.ui.OrbisTheme import io.visus.orbis.core.ui.OrbisTheme
import io.visus.orbis.ui.main.MainScreen import io.visus.orbis.feature.orb.ui.OrbScreen
import io.visus.orbis.ui.main.MainViewModel import io.visus.orbis.feature.orb.ui.OrbViewModel
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val viewModel: MainViewModel by viewModel() private val orbViewModel: OrbViewModel by viewModel()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -18,7 +18,7 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
OrbisTheme { OrbisTheme {
MainScreen(viewModel = viewModel) OrbScreen(viewModel = orbViewModel)
} }
} }
} }

View File

@@ -1,9 +1,6 @@
package io.visus.orbis.di package io.visus.orbis.di
import io.visus.orbis.ui.main.MainViewModel
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module import org.koin.dsl.module
val appModule = module { val appModule = module {
viewModelOf(::MainViewModel)
} }

View File

@@ -1,42 +0,0 @@
package io.visus.orbis.ui.main
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.visus.orbis.core.ui.OrbisTheme
@Composable
fun MainScreen(
viewModel: MainViewModel
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
MainScreenContent(uiState = uiState)
}
@Composable
private fun MainScreenContent(
uiState: MainUiState,
modifier: Modifier = Modifier
) {
Scaffold(modifier = modifier.fillMaxSize()) { innerPadding ->
Text(
text = "Hello ${uiState.greeting}!",
modifier = Modifier.padding(innerPadding)
)
}
}
@Preview(showBackground = true)
@Composable
private fun MainScreenPreview() {
OrbisTheme {
MainScreenContent(uiState = MainUiState())
}
}

View File

@@ -1,21 +0,0 @@
package io.visus.orbis.ui.main
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
data class MainUiState(
val greeting: String = "Android"
)
class MainViewModel : ViewModel() {
private val _uiState = MutableStateFlow(MainUiState())
val uiState: StateFlow<MainUiState> = _uiState.asStateFlow()
fun updateGreeting(name: String) {
_uiState.update { it.copy(greeting = name) }
}
}

View File

@@ -1 +0,0 @@
/build

View File

@@ -1,48 +0,0 @@
plugins {
alias(libs.plugins.android.library)
}
android {
namespace = "io.visus.orbis.data"
compileSdk {
version = release(36)
}
defaultConfig {
minSdk = 24
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
// Project modules
implementation(project(":core:domain"))
// AndroidX
implementation(libs.androidx.core.ktx)
// Koin
implementation(platform(libs.koin.bom))
implementation(libs.koin.core)
// Testing
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
}

View File

@@ -1,21 +0,0 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -1,24 +0,0 @@
package io.visus.orbis.data
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("io.visus.orbis.data.test", appContext.packageName)
}
}

View File

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

View File

@@ -1,17 +0,0 @@
package io.visus.orbis.data
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

View File

@@ -1 +0,0 @@
/build

View File

@@ -1,45 +0,0 @@
plugins {
alias(libs.plugins.android.library)
}
android {
namespace = "io.visus.orbis.domain"
compileSdk {
version = release(36)
}
defaultConfig {
minSdk = 24
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
// AndroidX
implementation(libs.androidx.core.ktx)
// Koin
implementation(platform(libs.koin.bom))
implementation(libs.koin.core)
// Testing
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
}

View File

@@ -1,21 +0,0 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -1,24 +0,0 @@
package io.visus.orbis.domain
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("io.visus.orbis.domain.test", appContext.packageName)
}
}

View File

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

View File

@@ -1,17 +0,0 @@
package io.visus.orbis.domain
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

View File

@@ -45,11 +45,15 @@ dependencies {
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.foundation) implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.compose.animation) implementation(libs.androidx.compose.animation)
implementation(libs.androidx.compose.material) implementation(libs.androidx.compose.material)
implementation(libs.androidx.compose.runtime) implementation(libs.androidx.compose.runtime)
// Composables (bottom sheet, slider)
api(libs.nomanr.composables)
// Testing // Testing
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)

View File

@@ -3,6 +3,7 @@ package io.visus.orbis.core.ui
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
val Black: Color = Color(0xFF000000) val Black: Color = Color(0xFF000000)
@@ -52,6 +53,9 @@ val Green200: Color = Color(0xFFC2F5DA)
val Green100: Color = Color(0xFFD0FBE9) val Green100: Color = Color(0xFFD0FBE9)
val Green50: Color = Color(0xFFE0FAEC) val Green50: Color = Color(0xFFE0FAEC)
val NavyBlue900: Color = Color(0xFF00002E)
val NavyBlue800: Color = Color(0xFF1A1A47)
@Immutable @Immutable
data class Colors( data class Colors(
val primary: Color, val primary: Color,
@@ -79,6 +83,7 @@ data class Colors(
val textDisabled: Color, val textDisabled: Color,
val scrim: Color, val scrim: Color,
val elevation: Color, val elevation: Color,
val defaultBackgroundGradient: Brush,
) )
internal val LightColors = internal val LightColors =
@@ -108,6 +113,9 @@ internal val LightColors =
textDisabled = Gray400, textDisabled = Gray400,
scrim = Color.Black.copy(alpha = 0.32f), scrim = Color.Black.copy(alpha = 0.32f),
elevation = Gray700, elevation = Gray700,
defaultBackgroundGradient = Brush.verticalGradient(
colors = listOf(NavyBlue800, NavyBlue900, Black)
),
) )
internal val DarkColors = internal val DarkColors =
@@ -137,6 +145,9 @@ internal val DarkColors =
textDisabled = Gray600, textDisabled = Gray600,
scrim = Color.Black.copy(alpha = 0.72f), scrim = Color.Black.copy(alpha = 0.72f),
elevation = Gray200, elevation = Gray200,
defaultBackgroundGradient = Brush.verticalGradient(
colors = listOf(NavyBlue800, NavyBlue900, Black)
),
) )
val LocalColors = staticCompositionLocalOf { LightColors } val LocalColors = staticCompositionLocalOf { LightColors }

View File

@@ -0,0 +1,652 @@
package io.visus.orbis.core.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
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.ButtonElevation
import androidx.compose.ui.tooling.preview.Preview
@Composable
fun IconButton(
modifier: Modifier = Modifier,
enabled: Boolean = true,
loading: Boolean = false,
variant: IconButtonVariant = IconButtonVariant.Primary,
shape: Shape = IconButtonDefaults.ButtonSquareShape,
onClick: () -> Unit = {},
contentPadding: PaddingValues = IconButtonDefaults.ButtonPadding,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
content: @Composable () -> Unit,
) {
val style = IconButtonDefaults.styleFor(variant, shape)
IconButtonComponent(
modifier = modifier,
enabled = enabled,
loading = loading,
style = style,
onClick = onClick,
contentPadding = contentPadding,
interactionSource = interactionSource,
content = content,
)
}
@Composable
private fun IconButtonComponent(
modifier: Modifier,
enabled: Boolean,
loading: Boolean,
style: IconButtonStyle,
onClick: () -> Unit,
contentPadding: PaddingValues,
interactionSource: MutableInteractionSource,
content: @Composable () -> Unit,
) {
val containerColor = style.colors.containerColor(enabled).value
val contentColor = style.colors.contentColor(enabled).value
val borderColor = style.colors.borderColor(enabled).value
val borderStroke = if (borderColor != null) BorderStroke(IconButtonDefaults.OutlineHeight, borderColor) else null
val shadowElevation = style.elevation?.shadowElevation(enabled, interactionSource)?.value ?: 0.dp
Surface(
onClick = onClick,
modifier =
modifier.defaultMinSize(
minWidth = IconButtonDefaults.ButtonSize,
minHeight = IconButtonDefaults.ButtonSize,
).semantics { role = Role.Button },
enabled = enabled,
shape = style.shape,
color = containerColor,
contentColor = contentColor,
border = borderStroke,
shadowElevation = shadowElevation,
interactionSource = interactionSource,
) {
Box(
modifier = Modifier.padding(contentPadding),
contentAlignment = Alignment.Center,
) {
// Add a loading indicator if needed
content()
}
}
}
enum class IconButtonVariant {
Primary,
PrimaryOutlined,
PrimaryElevated,
PrimaryGhost,
Secondary,
SecondaryOutlined,
SecondaryElevated,
SecondaryGhost,
Destructive,
DestructiveOutlined,
DestructiveElevated,
DestructiveGhost,
Ghost,
}
internal object IconButtonDefaults {
val ButtonSize = 44.dp
val ButtonPadding = PaddingValues(4.dp)
val ButtonSquareShape = RoundedCornerShape(12.dp)
val ButtonCircleShape = RoundedCornerShape(percent = 50)
val OutlineHeight = 1.dp
@Composable
fun buttonElevation() =
ButtonElevation(
defaultElevation = 2.dp,
pressedElevation = 2.dp,
focusedElevation = 2.dp,
hoveredElevation = 2.dp,
disabledElevation = 0.dp,
)
@Composable
fun styleFor(variant: IconButtonVariant, shape: Shape): IconButtonStyle {
return when (variant) {
IconButtonVariant.Primary -> primaryFilled(shape)
IconButtonVariant.PrimaryOutlined -> primaryOutlined(shape)
IconButtonVariant.PrimaryElevated -> primaryElevated(shape)
IconButtonVariant.PrimaryGhost -> primaryGhost(shape)
IconButtonVariant.Secondary -> secondaryFilled(shape)
IconButtonVariant.SecondaryOutlined -> secondaryOutlined(shape)
IconButtonVariant.SecondaryElevated -> secondaryElevated(shape)
IconButtonVariant.SecondaryGhost -> secondaryGhost(shape)
IconButtonVariant.Destructive -> destructiveFilled(shape)
IconButtonVariant.DestructiveOutlined -> destructiveOutlined(shape)
IconButtonVariant.DestructiveElevated -> destructiveElevated(shape)
IconButtonVariant.DestructiveGhost -> destructiveGhost(shape)
IconButtonVariant.Ghost -> ghost(shape)
}
}
@Composable
fun primaryFilled(shape: Shape) =
IconButtonStyle(
colors =
IconButtonColors(
containerColor = OrbisTheme.colors.primary,
contentColor = OrbisTheme.colors.onPrimary,
disabledContainerColor = OrbisTheme.colors.disabled,
disabledContentColor = OrbisTheme.colors.onDisabled,
),
shape = shape,
elevation = null,
)
@Composable
fun primaryOutlined(shape: Shape) =
IconButtonStyle(
colors =
IconButtonColors(
containerColor = OrbisTheme.colors.transparent,
contentColor = OrbisTheme.colors.primary,
borderColor = OrbisTheme.colors.primary,
disabledContainerColor = OrbisTheme.colors.transparent,
disabledContentColor = OrbisTheme.colors.onDisabled,
disabledBorderColor = OrbisTheme.colors.disabled,
),
shape = shape,
elevation = null,
)
@Composable
fun primaryElevated(shape: Shape) =
IconButtonStyle(
colors =
IconButtonColors(
containerColor = OrbisTheme.colors.primary,
contentColor = OrbisTheme.colors.onPrimary,
disabledContainerColor = OrbisTheme.colors.disabled,
disabledContentColor = OrbisTheme.colors.onDisabled,
),
shape = shape,
elevation = buttonElevation(),
)
@Composable
fun primaryGhost(shape: Shape) =
IconButtonStyle(
colors =
IconButtonColors(
containerColor = OrbisTheme.colors.transparent,
contentColor = OrbisTheme.colors.primary,
borderColor = OrbisTheme.colors.transparent,
disabledContainerColor = OrbisTheme.colors.transparent,
disabledContentColor = OrbisTheme.colors.onDisabled,
disabledBorderColor = OrbisTheme.colors.transparent,
),
shape = shape,
elevation = null,
)
@Composable
fun secondaryFilled(shape: Shape) =
IconButtonStyle(
colors =
IconButtonColors(
containerColor = OrbisTheme.colors.secondary,
contentColor = OrbisTheme.colors.onSecondary,
disabledContainerColor = OrbisTheme.colors.disabled,
disabledContentColor = OrbisTheme.colors.onDisabled,
),
shape = shape,
elevation = null,
)
@Composable
fun secondaryOutlined(shape: Shape) =
IconButtonStyle(
colors =
IconButtonColors(
containerColor = OrbisTheme.colors.transparent,
contentColor = OrbisTheme.colors.secondary,
borderColor = OrbisTheme.colors.secondary,
disabledContainerColor = OrbisTheme.colors.transparent,
disabledContentColor = OrbisTheme.colors.onDisabled,
disabledBorderColor = OrbisTheme.colors.disabled,
),
shape = shape,
elevation = null,
)
@Composable
fun secondaryElevated(shape: Shape) =
IconButtonStyle(
colors =
IconButtonColors(
containerColor = OrbisTheme.colors.secondary,
contentColor = OrbisTheme.colors.onSecondary,
disabledContainerColor = OrbisTheme.colors.disabled,
disabledContentColor = OrbisTheme.colors.onDisabled,
),
shape = shape,
elevation = buttonElevation(),
)
@Composable
fun secondaryGhost(shape: Shape) =
IconButtonStyle(
colors =
IconButtonColors(
containerColor = OrbisTheme.colors.transparent,
contentColor = OrbisTheme.colors.secondary,
borderColor = OrbisTheme.colors.transparent,
disabledContainerColor = OrbisTheme.colors.transparent,
disabledContentColor = OrbisTheme.colors.onDisabled,
disabledBorderColor = OrbisTheme.colors.transparent,
),
shape = shape,
elevation = null,
)
@Composable
fun destructiveFilled(shape: Shape) =
IconButtonStyle(
colors =
IconButtonColors(
containerColor = OrbisTheme.colors.error,
contentColor = OrbisTheme.colors.onError,
disabledContainerColor = OrbisTheme.colors.disabled,
disabledContentColor = OrbisTheme.colors.onDisabled,
),
shape = shape,
elevation = null,
)
@Composable
fun destructiveOutlined(shape: Shape) =
IconButtonStyle(
colors =
IconButtonColors(
containerColor = OrbisTheme.colors.transparent,
contentColor = OrbisTheme.colors.error,
borderColor = OrbisTheme.colors.error,
disabledContainerColor = OrbisTheme.colors.transparent,
disabledContentColor = OrbisTheme.colors.onDisabled,
disabledBorderColor = OrbisTheme.colors.disabled,
),
shape = shape,
elevation = null,
)
@Composable
fun destructiveElevated(shape: Shape) =
IconButtonStyle(
colors =
IconButtonColors(
containerColor = OrbisTheme.colors.error,
contentColor = OrbisTheme.colors.onError,
disabledContainerColor = OrbisTheme.colors.disabled,
disabledContentColor = OrbisTheme.colors.onDisabled,
),
shape = shape,
elevation = buttonElevation(),
)
@Composable
fun destructiveGhost(shape: Shape) =
IconButtonStyle(
colors =
IconButtonColors(
containerColor = OrbisTheme.colors.transparent,
contentColor = OrbisTheme.colors.error,
borderColor = OrbisTheme.colors.transparent,
disabledContainerColor = OrbisTheme.colors.transparent,
disabledContentColor = OrbisTheme.colors.onDisabled,
disabledBorderColor = OrbisTheme.colors.transparent,
),
shape = shape,
elevation = null,
)
@Composable
fun ghost(shape: Shape) =
IconButtonStyle(
colors =
IconButtonColors(
containerColor = OrbisTheme.colors.transparent,
contentColor = LocalContentColor.current,
disabledContainerColor = OrbisTheme.colors.transparent,
disabledContentColor = OrbisTheme.colors.onDisabled,
),
shape = shape,
elevation = null,
)
}
@Immutable
data class IconButtonColors(
val containerColor: Color,
val contentColor: Color,
val borderColor: Color? = null,
val disabledContainerColor: Color,
val disabledContentColor: Color,
val disabledBorderColor: Color? = null,
) {
@Composable
fun containerColor(enabled: Boolean) = rememberUpdatedState(if (enabled) containerColor else disabledContainerColor)
@Composable
fun contentColor(enabled: Boolean) = rememberUpdatedState(if (enabled) contentColor else disabledContentColor)
@Composable
fun borderColor(enabled: Boolean) = rememberUpdatedState(if (enabled) borderColor else disabledBorderColor)
}
@Immutable
data class IconButtonStyle(
val colors: IconButtonColors,
val shape: Shape,
val elevation: ButtonElevation? = null,
)
@Composable
@Preview
fun PrimaryIconButtonPreview() {
OrbisTheme {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
BasicText(text = "Primary Icon Buttons", style = OrbisTheme.typography.h2)
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
IconButton(variant = IconButtonVariant.Primary) {
DummyIconForIconButtonPreview()
}
IconButton(variant = IconButtonVariant.PrimaryOutlined) {
DummyIconForIconButtonPreview()
}
IconButton(variant = IconButtonVariant.PrimaryElevated) {
DummyIconForIconButtonPreview()
}
IconButton(variant = IconButtonVariant.PrimaryGhost) {
DummyIconForIconButtonPreview()
}
}
}
}
}
@Composable
@Preview
fun SecondaryIconButtonPreview() {
OrbisTheme {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
BasicText(text = "Secondary Icon Buttons", style = OrbisTheme.typography.h2)
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
IconButton(variant = IconButtonVariant.Secondary) {
DummyIconForIconButtonPreview()
}
IconButton(variant = IconButtonVariant.SecondaryOutlined) {
DummyIconForIconButtonPreview()
}
IconButton(variant = IconButtonVariant.SecondaryElevated) {
DummyIconForIconButtonPreview()
}
IconButton(variant = IconButtonVariant.SecondaryGhost) {
DummyIconForIconButtonPreview()
}
}
}
}
}
@Composable
@Preview
fun DestructiveIconButtonPreview() {
OrbisTheme {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
BasicText(text = "Destructive Icon Buttons", style = OrbisTheme.typography.h2)
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
IconButton(variant = IconButtonVariant.Destructive) {
DummyIconForIconButtonPreview()
}
IconButton(variant = IconButtonVariant.DestructiveOutlined) {
DummyIconForIconButtonPreview()
}
IconButton(variant = IconButtonVariant.DestructiveElevated) {
DummyIconForIconButtonPreview()
}
IconButton(variant = IconButtonVariant.DestructiveGhost) {
DummyIconForIconButtonPreview()
}
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
@Preview
fun GhostIconButtonPreview() {
OrbisTheme {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
BasicText(text = "Ghost Icon Buttons", style = OrbisTheme.typography.h2)
FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Box(
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(8)).background(OrbisTheme.colors.background),
contentAlignment = Alignment.Center,
) {
CompositionLocalProvider(LocalContentColor provides contentColorFor(color = OrbisTheme.colors.background)) {
IconButton(variant = IconButtonVariant.Ghost) {
DummyIconForIconButtonPreview()
}
}
}
Box(
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(8)).background(OrbisTheme.colors.primary),
contentAlignment = Alignment.Center,
) {
CompositionLocalProvider(LocalContentColor provides contentColorFor(color = OrbisTheme.colors.primary)) {
IconButton(variant = IconButtonVariant.Ghost) {
DummyIconForIconButtonPreview()
}
}
}
Box(
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(8)).background(OrbisTheme.colors.secondary),
contentAlignment = Alignment.Center,
) {
CompositionLocalProvider(LocalContentColor provides contentColorFor(color = OrbisTheme.colors.secondary)) {
IconButton(variant = IconButtonVariant.Ghost) {
DummyIconForIconButtonPreview()
}
}
}
Box(
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(8)).background(OrbisTheme.colors.tertiary),
contentAlignment = Alignment.Center,
) {
CompositionLocalProvider(LocalContentColor provides contentColorFor(color = OrbisTheme.colors.tertiary)) {
IconButton(variant = IconButtonVariant.Ghost) {
DummyIconForIconButtonPreview()
}
}
}
Box(
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(8)).background(OrbisTheme.colors.surface),
contentAlignment = Alignment.Center,
) {
CompositionLocalProvider(LocalContentColor provides contentColorFor(color = OrbisTheme.colors.surface)) {
IconButton(variant = IconButtonVariant.Ghost) {
DummyIconForIconButtonPreview()
}
}
}
Box(
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(8)).background(OrbisTheme.colors.error),
contentAlignment = Alignment.Center,
) {
CompositionLocalProvider(LocalContentColor provides contentColorFor(color = OrbisTheme.colors.error)) {
IconButton(variant = IconButtonVariant.Ghost) {
DummyIconForIconButtonPreview()
}
}
}
}
}
}
}
@Composable
@Preview
fun IconButtonShapesPreview() {
OrbisTheme {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
BasicText(text = "Square Shape", style = OrbisTheme.typography.h2)
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
IconButton(
variant = IconButtonVariant.Primary,
shape = IconButtonDefaults.ButtonSquareShape,
) {
DummyIconForIconButtonPreview()
}
IconButton(
variant = IconButtonVariant.PrimaryOutlined,
shape = IconButtonDefaults.ButtonSquareShape,
) {
DummyIconForIconButtonPreview()
}
}
BasicText(text = "Circle Shape", style = OrbisTheme.typography.h2)
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
IconButton(
variant = IconButtonVariant.Primary,
shape = IconButtonDefaults.ButtonCircleShape,
) {
DummyIconForIconButtonPreview()
}
IconButton(
variant = IconButtonVariant.PrimaryOutlined,
shape = IconButtonDefaults.ButtonCircleShape,
) {
DummyIconForIconButtonPreview()
}
}
}
}
}
@Composable
@Preview
private fun DummyIconForIconButtonPreview() {
Canvas(modifier = Modifier.size(16.dp)) {
val center = size / 2f
val radius = size.minDimension * 0.4f
val strokeWidth = 4f
val cap = StrokeCap.Round
drawLine(
color = Color.Black,
start = Offset(center.width - radius, center.height),
end = Offset(center.width + radius, center.height),
strokeWidth = strokeWidth,
cap = cap,
)
drawLine(
color = Color.Black,
start = Offset(center.width, center.height - radius),
end = Offset(center.width, center.height + radius),
strokeWidth = strokeWidth,
cap = cap,
)
val diagonalRadius = radius * 0.75f
drawLine(
color = Color.Black,
start =
Offset(
center.width - diagonalRadius,
center.height - diagonalRadius,
),
end =
Offset(
center.width + diagonalRadius,
center.height + diagonalRadius,
),
strokeWidth = strokeWidth,
cap = cap,
)
drawLine(
color = Color.Black,
start =
Offset(
center.width - diagonalRadius,
center.height + diagonalRadius,
),
end =
Offset(
center.width + diagonalRadius,
center.height - diagonalRadius,
),
strokeWidth = strokeWidth,
cap = cap,
)
}
}

View File

@@ -0,0 +1,92 @@
package io.visus.orbis.core.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.nomanr.composables.bottomsheet.BasicModalBottomSheet
import com.nomanr.composables.bottomsheet.SheetState
import com.nomanr.composables.bottomsheet.rememberModalBottomSheetState
import io.visus.orbis.core.ui.OrbisTheme
import androidx.compose.ui.tooling.preview.Preview
@Composable
fun ModalBottomSheet(
modifier: Modifier = Modifier,
sheetState: SheetState = rememberModalBottomSheetState(),
isVisible: Boolean,
onDismissRequest: () -> Unit,
sheetGesturesEnabled: Boolean = true,
dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
content: @Composable ColumnScope.() -> Unit,
) {
if (isVisible) {
BasicModalBottomSheet(
modifier = modifier,
sheetState = sheetState,
onDismissRequest = onDismissRequest,
sheetGesturesEnabled = sheetGesturesEnabled,
containerColor = OrbisTheme.colors.background,
scrimColor = OrbisTheme.colors.scrim,
shape = BottomSheetDefaults.ModalBottomSheetShape,
dragHandle = dragHandle,
content = content,
)
}
}
internal object BottomSheetDefaults {
private val DragHandleHeight = 6.dp
private val DragHandleWidth = 36.dp
private val DragHandleShape = RoundedCornerShape(50)
private val DragHandlePadding = 12.dp
val ModalBottomSheetShape =
RoundedCornerShape(
topStart = 16.dp,
topEnd = 16.dp,
)
@Composable
fun DragHandle() {
Box(
modifier =
Modifier
.padding(DragHandlePadding)
.background(color = Color.Companion.Unspecified, shape = RoundedCornerShape(12.dp)),
) {
Spacer(
Modifier
.size(width = DragHandleWidth, height = DragHandleHeight)
.background(color = OrbisTheme.colors.secondary, shape = DragHandleShape),
)
}
}
}
@Preview
@Composable
fun ModalBottomSheetPreview() {
ModalBottomSheet(isVisible = true, onDismissRequest = { }) {
Column {
for (i in 0..10) {
Box(
modifier =
Modifier
.height(40.dp)
.fillMaxWidth()
.background(color = if (i % 2 == 0) Color.Red else Color.Blue),
)
}
}
}
}

View File

@@ -1,318 +0,0 @@
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<Color> {
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<Color> {
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"

View File

@@ -0,0 +1,438 @@
package io.visus.orbis.core.ui.components
import androidx.annotation.IntRange
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.nomanr.composables.slider.BasicRangeSlider
import com.nomanr.composables.slider.BasicSlider
import com.nomanr.composables.slider.RangeSliderState
import com.nomanr.composables.slider.SliderColors
import com.nomanr.composables.slider.SliderState
import io.visus.orbis.core.ui.OrbisTheme
import androidx.compose.ui.tooling.preview.Preview
@Composable
fun Slider(
value: Float,
onValueChange: (Float) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
onValueChangeFinished: (() -> Unit)? = null,
colors: SliderColors = SliderDefaults.colors(),
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
@IntRange(from = 0) steps: Int = 0,
valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
) {
val state =
remember(steps, valueRange) {
SliderState(
value,
steps,
onValueChangeFinished,
valueRange,
)
}
state.onValueChangeFinished = onValueChangeFinished
state.onValueChange = onValueChange
state.value = value
Slider(
state = state,
modifier = modifier,
enabled = enabled,
interactionSource = interactionSource,
colors = colors,
)
}
@Composable
fun Slider(
state: SliderState,
modifier: Modifier = Modifier,
enabled: Boolean = true,
colors: SliderColors = SliderDefaults.colors(),
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
require(state.steps >= 0) { "steps should be >= 0" }
BasicSlider(modifier = modifier, state = state, colors = colors, enabled = enabled, interactionSource = interactionSource)
}
@Composable
fun RangeSlider(
value: ClosedFloatingPointRange<Float>,
onValueChange: (ClosedFloatingPointRange<Float>) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
@IntRange(from = 0) steps: Int = 0,
onValueChangeFinished: (() -> Unit)? = null,
colors: SliderColors = SliderDefaults.colors(),
startInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
endInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val state =
remember(steps, valueRange) {
RangeSliderState(
value.start,
value.endInclusive,
steps,
onValueChangeFinished,
valueRange,
)
}
state.onValueChangeFinished = onValueChangeFinished
state.onValueChange = { onValueChange(it.start..it.endInclusive) }
state.activeRangeStart = value.start
state.activeRangeEnd = value.endInclusive
RangeSlider(
state = state,
modifier = modifier,
enabled = enabled,
colors = colors,
startInteractionSource = startInteractionSource,
endInteractionSource = endInteractionSource,
)
}
@Composable
fun RangeSlider(
state: RangeSliderState,
modifier: Modifier = Modifier,
enabled: Boolean = true,
colors: SliderColors = SliderDefaults.colors(),
startInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
endInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
require(state.steps >= 0) { "steps should be >= 0" }
BasicRangeSlider(
modifier = modifier,
state = state,
enabled = enabled,
startInteractionSource = startInteractionSource,
endInteractionSource = endInteractionSource,
colors = colors,
)
}
@Stable
object SliderDefaults {
@Composable
fun colors(
thumbColor: Color = OrbisTheme.colors.primary,
activeTrackColor: Color = OrbisTheme.colors.primary,
activeTickColor: Color = OrbisTheme.colors.onPrimary,
inactiveTrackColor: Color = OrbisTheme.colors.secondary,
inactiveTickColor: Color = OrbisTheme.colors.primary,
disabledThumbColor: Color = OrbisTheme.colors.disabled,
disabledActiveTrackColor: Color = OrbisTheme.colors.disabled,
disabledActiveTickColor: Color = OrbisTheme.colors.disabled,
disabledInactiveTrackColor: Color = OrbisTheme.colors.disabled,
disabledInactiveTickColor: Color = Color.Unspecified,
) = SliderColors(
thumbColor = thumbColor,
activeTrackColor = activeTrackColor,
activeTickColor = activeTickColor,
inactiveTrackColor = inactiveTrackColor,
inactiveTickColor = inactiveTickColor,
disabledThumbColor = disabledThumbColor,
disabledActiveTrackColor = disabledActiveTrackColor,
disabledActiveTickColor = disabledActiveTickColor,
disabledInactiveTrackColor = disabledInactiveTrackColor,
disabledInactiveTickColor = disabledInactiveTickColor,
)
}
@Preview
@Composable
private fun SliderPreview() {
OrbisTheme {
Column(
modifier =
Modifier
.background(Color.White)
.verticalScroll(rememberScrollState())
.padding(16.dp)
.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(32.dp),
) {
BasicText(
text = "Slider Components",
style = OrbisTheme.typography.h3,
)
Column {
BasicText(
text = "Basic Slider",
style = OrbisTheme.typography.h4,
)
var value by remember { mutableFloatStateOf(0.5f) }
Slider(
value = value,
onValueChange = { value = it },
modifier = Modifier.fillMaxWidth(),
)
}
Column {
BasicText(
text = "Stepped Slider (5 steps)",
style = OrbisTheme.typography.h4,
)
var value by remember { mutableFloatStateOf(0.4f) }
Slider(
value = value,
onValueChange = { value = it },
steps = 4,
modifier = Modifier.fillMaxWidth(),
)
}
Column {
BasicText(
text = "Custom Range (0-100)",
style = OrbisTheme.typography.h4,
)
var value by remember { mutableFloatStateOf(30f) }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Slider(
value = value,
onValueChange = { value = it },
valueRange = 0f..100f,
modifier = Modifier.weight(1f),
)
BasicText(
text = "${value.toInt()}",
style = OrbisTheme.typography.body1,
modifier = Modifier.width(40.dp),
)
}
}
Column {
BasicText(
text = "Disabled States",
style = OrbisTheme.typography.h4,
)
Slider(
value = 0.3f,
onValueChange = {},
enabled = false,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
Slider(
value = 0.7f,
onValueChange = {},
enabled = false,
modifier = Modifier.fillMaxWidth(),
)
}
Column {
BasicText(
text = "Custom Colors",
style = OrbisTheme.typography.h4,
)
var value by remember { mutableFloatStateOf(0.5f) }
Slider(
value = value,
onValueChange = { value = it },
colors =
SliderDefaults.colors(
thumbColor = OrbisTheme.colors.error,
activeTrackColor = OrbisTheme.colors.error,
inactiveTrackColor = OrbisTheme.colors.error.copy(alpha = 0.3f),
),
modifier = Modifier.fillMaxWidth(),
)
}
Column {
BasicText(
text = "Interactive Slider",
style = OrbisTheme.typography.h4,
)
var value by remember { mutableFloatStateOf(50f) }
var isEditing by remember { mutableStateOf(false) }
BasicText(
text = if (isEditing) "Editing..." else "Value: ${value.toInt()}",
style = OrbisTheme.typography.body1,
)
Slider(
value = value,
onValueChange = {
value = it
isEditing = true
},
valueRange = 0f..100f,
onValueChangeFinished = { isEditing = false },
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
@Preview
@Composable
private fun RangeSliderPreview() {
OrbisTheme {
Column(
modifier =
Modifier
.background(Color.White)
.verticalScroll(rememberScrollState())
.padding(16.dp)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(32.dp),
) {
BasicText(
text = "Range Slider Components",
style = OrbisTheme.typography.h3,
)
Column {
BasicText(
text = "Basic Range Slider",
style = OrbisTheme.typography.h4,
)
var range by remember { mutableStateOf(0.2f..0.8f) }
RangeSlider(
value = range,
onValueChange = { range = it },
modifier = Modifier.fillMaxWidth(),
)
}
Column {
BasicText(
text = "Stepped Range Slider (5 steps)",
style = OrbisTheme.typography.h4,
)
var range by remember { mutableStateOf(0.2f..0.6f) }
RangeSlider(
value = range,
onValueChange = { range = it },
steps = 4,
modifier = Modifier.fillMaxWidth(),
)
}
Column {
BasicText(
text = "Custom Range (0-100)",
style = OrbisTheme.typography.h4,
)
var range by remember { mutableStateOf(20f..80f) }
Column {
RangeSlider(
value = range,
onValueChange = { range = it },
valueRange = 0f..100f,
modifier = Modifier.fillMaxWidth(),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
BasicText(
text = "Start: ${range.start.toInt()}",
style = OrbisTheme.typography.body1,
)
BasicText(
text = "End: ${range.endInclusive.toInt()}",
style = OrbisTheme.typography.body1,
)
}
}
}
Column {
BasicText(
text = "Disabled State",
style = OrbisTheme.typography.h4,
)
RangeSlider(
value = 0.3f..0.7f,
onValueChange = {},
enabled = false,
modifier = Modifier.fillMaxWidth(),
)
}
Column {
BasicText(
text = "Custom Colors",
style = OrbisTheme.typography.h4,
)
var range by remember { mutableStateOf(0.3f..0.7f) }
RangeSlider(
value = range,
onValueChange = { range = it },
colors =
SliderDefaults.colors(
thumbColor = OrbisTheme.colors.error,
activeTrackColor = OrbisTheme.colors.error,
inactiveTrackColor = OrbisTheme.colors.error.copy(alpha = 0.3f),
),
modifier = Modifier.fillMaxWidth(),
)
}
Column {
BasicText(
text = "Interactive Range Slider",
style = OrbisTheme.typography.h4,
)
var range by remember { mutableStateOf(30f..70f) }
var isEditing by remember { mutableStateOf(false) }
BasicText(
text = if (isEditing) "Editing..." else "Range: ${range.start.toInt()} - ${range.endInclusive.toInt()}",
style = OrbisTheme.typography.body1,
)
RangeSlider(
value = range,
onValueChange = {
range = it
isEditing = true
},
valueRange = 0f..100f,
onValueChangeFinished = { isEditing = false },
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}

View File

@@ -0,0 +1,133 @@
package io.visus.orbis.core.ui.foundation
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.VectorConverter
import androidx.compose.foundation.interaction.FocusInteraction
import androidx.compose.foundation.interaction.HoverInteraction
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.Dp
@Stable
class ButtonElevation internal constructor(
private val defaultElevation: Dp,
private val pressedElevation: Dp,
private val focusedElevation: Dp,
private val hoveredElevation: Dp,
private val disabledElevation: Dp,
) {
@Composable
internal fun shadowElevation(
enabled: Boolean,
interactionSource: InteractionSource,
): State<Dp> {
return animateElevation(enabled = enabled, interactionSource = interactionSource)
}
@Composable
private fun animateElevation(
enabled: Boolean,
interactionSource: InteractionSource,
): State<Dp> {
val interactions = remember { mutableStateListOf<Interaction>() }
LaunchedEffect(interactionSource) {
interactionSource.interactions.collect { interaction ->
when (interaction) {
is HoverInteraction.Enter -> {
interactions.add(interaction)
}
is HoverInteraction.Exit -> {
interactions.remove(interaction.enter)
}
is FocusInteraction.Focus -> {
interactions.add(interaction)
}
is FocusInteraction.Unfocus -> {
interactions.remove(interaction.focus)
}
is PressInteraction.Press -> {
interactions.add(interaction)
}
is PressInteraction.Release -> {
interactions.remove(interaction.press)
}
is PressInteraction.Cancel -> {
interactions.remove(interaction.press)
}
}
}
}
val interaction = interactions.lastOrNull()
val target =
if (!enabled) {
disabledElevation
} else {
when (interaction) {
is PressInteraction.Press -> pressedElevation
is HoverInteraction.Enter -> hoveredElevation
is FocusInteraction.Focus -> focusedElevation
else -> defaultElevation
}
}
val animatable = remember { Animatable(target, Dp.VectorConverter) }
if (!enabled) {
// No transition when moving to a disabled state
LaunchedEffect(target) { animatable.snapTo(target) }
} else {
LaunchedEffect(target) {
val lastInteraction =
when (animatable.targetValue) {
pressedElevation -> PressInteraction.Press(Offset.Zero)
hoveredElevation -> HoverInteraction.Enter()
focusedElevation -> FocusInteraction.Focus()
else -> null
}
animatable.animateElevation(
from = lastInteraction,
to = interaction,
target = target,
)
}
}
return animatable.asState()
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || other !is ButtonElevation) return false
if (defaultElevation != other.defaultElevation) return false
if (pressedElevation != other.pressedElevation) return false
if (focusedElevation != other.focusedElevation) return false
if (hoveredElevation != other.hoveredElevation) return false
return disabledElevation == other.disabledElevation
}
override fun hashCode(): Int {
var result = defaultElevation.hashCode()
result = 31 * result + pressedElevation.hashCode()
result = 31 * result + focusedElevation.hashCode()
result = 31 * result + hoveredElevation.hashCode()
result = 31 * result + disabledElevation.hashCode()
return result
}
}

View File

@@ -37,7 +37,6 @@ android {
dependencies { dependencies {
// Project modules // Project modules
implementation(project(":core:ui")) implementation(project(":core:ui"))
implementation(project(":core:domain"))
// AndroidX // AndroidX
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
@@ -49,6 +48,7 @@ dependencies {
implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons)
implementation(libs.androidx.compose.foundation) implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.compose.runtime) implementation(libs.androidx.compose.runtime)

View File

@@ -22,11 +22,21 @@
// Constants // Constants
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Background radial gradient colors (light gray center to darker gray edge)
// #d0d0d0 at center (0%)
const GRADIENT_CENTER: vec3<f32> = vec3<f32>(0.816, 0.816, 0.816);
// #a8a8a8 at 70%
const GRADIENT_MID: vec3<f32> = vec3<f32>(0.659, 0.659, 0.659);
// #909090 at edge (100%)
const GRADIENT_EDGE: vec3<f32> = vec3<f32>(0.565, 0.565, 0.565);
// Surface intersection threshold - rays closer than this are considered hits // Surface intersection threshold - rays closer than this are considered hits
const EPS: f32 = 0.01; // Larger value = faster convergence but less precision (mobile optimization)
const EPS: f32 = 0.04;
// Maximum raymarching iterations to prevent infinite loops // Maximum raymarching iterations to prevent infinite loops
const MAX_ITR: i32 = 100; // Reduced from 100 for mobile GPU performance
const MAX_ITR: i32 = 32;
// Maximum ray travel distance before giving up (ray miss) // Maximum ray travel distance before giving up (ray miss)
const MAX_DIS: f32 = 10.0; const MAX_DIS: f32 = 10.0;
@@ -104,31 +114,47 @@ fn sd_sph(p: vec3<f32>, r: f32) -> f32 {
// Evaluates the distance field at point `p`. // Evaluates the distance field at point `p`.
// Combines a base sphere with animated noise displacement. // Combines a base sphere with animated noise displacement.
fn map(p: vec3<f32>) -> f32 { fn map(p: vec3<f32>) -> f32 {
// Base UV from world position (scaled down for tiling) // Animated UV for displacement (single sample for mobile performance)
let u = p.xy * 0.2; var um = p.xy * 0.06;
um.x = um.x + uniforms.time * 0.1;
um.y = um.y - uniforms.time * 0.025;
um.x = um.x + um.y * 2.0;
// Animated UV for large-scale displacement // Single noise sample (mobile optimization - was 2 samples)
// Creates slow, flowing motion across the surface let noise = textureSampleLevel(noiseTexture, texSampler, um, 0.0).x;
var um = u * 0.3;
um.x = um.x + uniforms.time * 0.1; // Horizontal drift
um.y = um.y - uniforms.time * 0.025; // Slower vertical drift
um.x = um.x + um.y * 2.0; // Shear for diagonal motion
// Sample noise at two frequencies: // Apply displacement with amplitude control
// - hlg: Large-scale, animated features (the "goo" blobs)
// - hfn: Fine detail, static relative to surface
let hlg = textureSampleLevel(noiseTexture, texSampler, um, 0.0).x;
let hfn = textureSampleLevel(noiseTexture, texSampler, u, 0.0).x;
// Combine noise samples with amplitude control
// Fine detail is reduced where large features are prominent
let amp = max(uniforms.amplitude, 0.15); let amp = max(uniforms.amplitude, 0.15);
let disp = (hlg * 0.4 + hfn * 0.1 * (1.0 - hlg)) * amp; let disp = noise * 0.5 * amp;
// Return displaced sphere distance
return sd_sph(p, 1.5) + disp; return sd_sph(p, 1.5) + disp;
} }
// ----------------------------------------------------------------------------
// Background Gradient
// ----------------------------------------------------------------------------
// Computes the radial gradient background color based on distance from center.
// Gradient: #d0d0d0 (center) -> #a8a8a8 (70%) -> #909090 (edge)
fn gradient_background(uv: vec2<f32>, aspect: f32) -> vec3<f32> {
// Center the UV coordinates
var centered = uv - vec2<f32>(0.5, 0.5);
// Apply aspect ratio correction so gradient is circular not elliptical
centered.x = centered.x * aspect;
// Distance from center (0 at center, ~0.5-0.7 at edges depending on aspect)
let dist = length(centered) * 2.0; // Scale so edges are roughly 1.0
if (dist < 0.7) {
// Center to 70%: blend from CENTER to MID
let t = dist / 0.7;
return mix(GRADIENT_CENTER, GRADIENT_MID, t);
} else {
// 70% to edge: blend from MID to EDGE
let t = min((dist - 0.7) / 0.3, 1.0);
return mix(GRADIENT_MID, GRADIENT_EDGE, t);
}
}
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Lighting Utilities // Lighting Utilities
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@@ -162,15 +188,32 @@ fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
// ------------------------- // -------------------------
// Camera Setup // Camera Setup
// ------------------------- // -------------------------
let campos = vec3<f32>(0.0, 0.0, -2.9); // Camera position (in front of sphere) let campos = vec3<f32>(0.0, 0.0, -5.5); // Camera position (moved back for smaller orb)
let raydir = normalize(vec3<f32>(d.x, -d.y, 1.0)); // Ray direction per pixel let raydir = normalize(vec3<f32>(d.x, -d.y, 1.0)); // Ray direction per pixel
// -------------------------
// Early-out: Skip rays that clearly miss the sphere
// -------------------------
// Ray-sphere intersection test for bounding sphere (radius 2.0 to account for displacement)
let oc = campos; // origin to center (center is at origin)
let b = dot(oc, raydir);
let c = dot(oc, oc) - 4.0; // 4.0 = 2.0^2 (bounding radius)
let discriminant = b * b - c;
// If ray misses bounding sphere entirely, return gradient background
if (discriminant < 0.0) {
let bg = gradient_background(uv, uniforms.aspectRatio);
return vec4<f32>(bg, 1.0);
}
// ------------------------- // -------------------------
// Raymarching Loop // Raymarching Loop
// ------------------------- // -------------------------
var pos = campos; // Start ray at intersection with bounding sphere for faster convergence
var tdist: f32 = 0.0; // Total distance traveled let tstart = max(0.0, -b - sqrt(discriminant));
var dist: f32 = EPS; // Current step distance var pos = campos + tstart * raydir;
var tdist: f32 = tstart;
var dist: f32 = EPS;
for (var i: i32 = 0; i < MAX_ITR; i = i + 1) { for (var i: i32 = 0; i < MAX_ITR; i = i + 1) {
if (dist < EPS || tdist > MAX_DIS) { if (dist < EPS || tdist > MAX_DIS) {
@@ -185,13 +228,15 @@ fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
// Shading (on hit) // Shading (on hit)
// ------------------------- // -------------------------
if (dist < EPS) { if (dist < EPS) {
// Compute surface normal via central differences // Compute surface normal via tetrahedral sampling (4 samples instead of 6)
let eps = vec2<f32>(0.0, EPS); // More efficient than central differences while maintaining quality
let normal = normalize(vec3<f32>( let e = vec2<f32>(1.0, -1.0) * 0.5773 * EPS;
map(pos + eps.yxx) - map(pos - eps.yxx), let normal = normalize(
map(pos + eps.xyx) - map(pos - eps.xyx), e.xyy * map(pos + e.xyy) +
map(pos + eps.xxy) - map(pos - eps.xxy) e.yyx * map(pos + e.yyx) +
)); e.yxy * map(pos + e.yxy) +
e.xxx * map(pos + e.xxx)
);
// Diffuse lighting (Lambertian) // Diffuse lighting (Lambertian)
let diffuse = max(0.0, dot(LIGHT_DIR, normal)); let diffuse = max(0.0, dot(LIGHT_DIR, normal));
@@ -219,7 +264,8 @@ fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
} }
// ------------------------- // -------------------------
// Background (ray miss) // Background (ray miss after marching)
// ------------------------- // -------------------------
return vec4<f32>(0.0, 0.0, 0.0, 0.0); let bg = gradient_background(uv, uniforms.aspectRatio);
return vec4<f32>(bg, 1.0);
} }

View File

@@ -0,0 +1,29 @@
package io.visus.orbis.feature.orb.data
import androidx.compose.ui.graphics.Color
/**
* Represents the rendering state of an orb visualization.
*
* @property color The color of the orb. Defaults to a blue-gray shade (0xFF547691).
* @property amplitude The amplitude of the orb's animation, ranging from 0.0 to 1.0. Defaults to 0.5.
*/
data class OrbRenderState(
val color: Color = Color(0xFF547691),
val amplitude: Float = 0.5f
)
/**
* Represents the UI state of the orb feature.
*
* @property renderState The current rendering configuration of the orb.
* @property isInitialized Whether the orb has been successfully initialized.
* @property error An error message if something went wrong, or null if no error occurred.
* @property fps The current frames per second of the orb rendering.
*/
data class OrbUiState(
val renderState: OrbRenderState = OrbRenderState(),
val isInitialized: Boolean = false,
val error: String? = null,
val fps: Int = 0
)

View File

@@ -1,6 +1,14 @@
package io.visus.orbis.feature.orb.di package io.visus.orbis.feature.orb.di
import io.visus.orbis.feature.orb.service.OrbRenderService
import io.visus.orbis.feature.orb.service.OrbRenderServiceImpl
import io.visus.orbis.feature.orb.ui.OrbViewModel
import org.koin.core.module.dsl.bind
import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module import org.koin.dsl.module
val orbModule = module { val orbModule = module {
singleOf(::OrbRenderServiceImpl) { bind<OrbRenderService>() }
viewModelOf(::OrbViewModel)
} }

View File

@@ -0,0 +1,110 @@
package io.visus.orbis.feature.orb.service
import android.content.Context
import android.view.Surface
import io.visus.orbis.feature.orb.data.OrbRenderState
import kotlinx.coroutines.flow.StateFlow
/**
* Service interface for rendering the orb using WebGPU.
*
* This service manages the complete lifecycle of WebGPU rendering, including GPU resource
* initialization, surface management, and frame rendering. It exposes reactive state flows
* for monitoring initialization status, errors, and performance metrics.
*
* Typical usage:
* 1. Call [initialize] to set up WebGPU resources
* 2. Call [attachSurface] when a rendering surface becomes available
* 3. Call [startRenderLoop] to begin continuous rendering
* 4. Update visual parameters via [updateRenderState]
* 5. Call [stopRenderLoop] and [detachSurface] when the surface is no longer available
* 6. Call [release] to clean up all GPU resources
*/
interface OrbRenderService {
/**
* Emits `true` when WebGPU initialization is complete and rendering is possible.
*/
val isInitialized: StateFlow<Boolean>
/**
* Emits error messages when initialization or rendering fails, `null` otherwise.
*/
val error: StateFlow<String?>
/**
* Emits the current frames per second, updated approximately once per second.
*/
val fps: StateFlow<Int>
/**
* Initializes WebGPU resources including the GPU instance, adapter, device, shaders,
* textures, and pipeline layouts.
*
* This must be called before any other rendering operations. The [isInitialized] flow
* will emit `true` upon successful completion, or [error] will emit a message on failure.
*
* @param context Android context used to load shader and texture assets.
*/
suspend fun initialize(context: Context)
/**
* Attaches a rendering surface and configures it for WebGPU output.
*
* This creates the GPU surface, configures its format and size, and builds the render
* pipeline. Must be called after [initialize] and before [startRenderLoop].
*
* @param surface The Android [Surface] to render to.
* @param width The surface width in pixels.
* @param height The surface height in pixels.
*/
suspend fun attachSurface(surface: Surface, width: Int, height: Int)
/**
* Detaches and releases the current rendering surface.
*
* This stops any active render loop and unconfigures the GPU surface. Call this when
* the surface is destroyed or no longer available for rendering.
*/
fun detachSurface()
/**
* Updates the render state parameters used for the next frame.
*
* @param state The new [OrbRenderState] containing color and amplitude values.
*/
fun updateRenderState(state: OrbRenderState)
/**
* Renders a single frame to the attached surface.
*
* This updates uniforms, acquires a surface texture, executes the render pass,
* and presents the result. Typically called automatically by the render loop,
* but can be invoked manually for single-frame rendering.
*/
fun render()
/**
* Starts the continuous render loop synchronized with the display refresh rate.
*
* Uses [android.view.Choreographer] to schedule frame callbacks, ensuring smooth
* rendering aligned with VSync. The loop continues until [stopRenderLoop] is called.
*/
fun startRenderLoop()
/**
* Stops the continuous render loop.
*
* Frame callbacks are removed and no further frames will be rendered automatically.
*/
fun stopRenderLoop()
/**
* Releases all WebGPU resources and resets the service to an uninitialized state.
*
* This stops any active render loop and closes all GPU objects including the device,
* adapter, textures, buffers, and pipelines. After calling this, [initialize] must
* be called again before rendering.
*/
fun release()
}

View File

@@ -0,0 +1,611 @@
@file:Suppress("WrongConstant")
package io.visus.orbis.feature.orb.service
import android.content.Context
import android.graphics.BitmapFactory
import android.os.Handler
import android.os.Looper
import android.view.Choreographer
import android.view.Surface
import androidx.webgpu.GPU
import androidx.webgpu.GPUAdapter
import androidx.webgpu.GPUBindGroup
import androidx.webgpu.GPUBindGroupDescriptor
import androidx.webgpu.GPUBindGroupEntry
import androidx.webgpu.GPUBindGroupLayout
import androidx.webgpu.GPUBindGroupLayoutDescriptor
import androidx.webgpu.GPUBindGroupLayoutEntry
import androidx.webgpu.GPUBuffer
import androidx.webgpu.GPUBufferBindingLayout
import androidx.webgpu.GPUBufferDescriptor
import androidx.webgpu.GPUColor
import androidx.webgpu.GPUColorTargetState
import androidx.webgpu.GPUDevice
import androidx.webgpu.GPUExtent3D
import androidx.webgpu.GPUFragmentState
import androidx.webgpu.GPUInstance
import androidx.webgpu.GPUOrigin3D
import androidx.webgpu.GPUPipelineLayout
import androidx.webgpu.GPUPipelineLayoutDescriptor
import androidx.webgpu.GPURenderPassColorAttachment
import androidx.webgpu.GPURenderPassDescriptor
import androidx.webgpu.GPURenderPipeline
import androidx.webgpu.GPURenderPipelineDescriptor
import androidx.webgpu.GPUSampler
import androidx.webgpu.GPUSamplerBindingLayout
import androidx.webgpu.GPUSamplerDescriptor
import androidx.webgpu.GPUShaderModule
import androidx.webgpu.GPUShaderModuleDescriptor
import androidx.webgpu.GPUShaderSourceWGSL
import androidx.webgpu.GPUSurface
import androidx.webgpu.GPUSurfaceConfiguration
import androidx.webgpu.GPUSurfaceDescriptor
import androidx.webgpu.GPUSurfaceSourceAndroidNativeWindow
import androidx.webgpu.GPUTexelCopyBufferLayout
import androidx.webgpu.GPUTexelCopyTextureInfo
import androidx.webgpu.GPUTexture
import androidx.webgpu.GPUTextureBindingLayout
import androidx.webgpu.GPUTextureDescriptor
import androidx.webgpu.GPUTextureView
import androidx.webgpu.GPUTextureViewDescriptor
import androidx.webgpu.GPUVertexState
import androidx.webgpu.helper.Util
import androidx.webgpu.helper.initLibrary
import io.visus.orbis.feature.orb.data.OrbRenderState
import io.visus.orbis.feature.orb.util.CubemapGenerator
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* WebGPU-based implementation of [OrbRenderService].
*
* This implementation renders a procedural orb effect using WGSL shaders with support for:
* - Noise texture sampling for surface distortion
* - Cubemap environment reflections
* - Dynamic color and amplitude parameters via uniform buffers
*
* The renderer uses a reduced resolution (60% by default) for performance optimization
* while maintaining the correct aspect ratio. Frame timing is synchronized with the
* display's VSync via [android.view.Choreographer].
*
* GPU resources are allocated during [initialize] and [attachSurface], and must be
* explicitly released via [release] to avoid memory leaks.
*/
class OrbRenderServiceImpl : OrbRenderService {
private val _isInitialized = MutableStateFlow(false)
override val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
override val error: StateFlow<String?> = _error.asStateFlow()
private val _fps = MutableStateFlow(0)
override val fps: StateFlow<Int> = _fps.asStateFlow()
private var instance: GPUInstance? = null
private var adapter: GPUAdapter? = null
private var device: GPUDevice? = null
private var gpuSurface: GPUSurface? = null
private var shaderModule: GPUShaderModule? = null
private var uniformBuffer: GPUBuffer? = null
private var noiseTexture: GPUTexture? = null
private var noiseTextureView: GPUTextureView? = null
private var cubemapTexture: GPUTexture? = null
private var cubemapTextureView: GPUTextureView? = null
private var sampler: GPUSampler? = null
private var bindGroupLayout: GPUBindGroupLayout? = null
private var pipelineLayout: GPUPipelineLayout? = null
private var renderPipeline: GPURenderPipeline? = null
private var bindGroup: GPUBindGroup? = null
// State
@Volatile private var currentState = OrbRenderState()
private var surfaceFormat: Int = TEXTURE_FORMAT_BGRA8_UNORM
private var surfaceWidth: Int = 0
private var surfaceHeight: Int = 0
private var renderWidth: Int = 0
private var renderHeight: Int = 0
private var startTime: Long = 0L
@Volatile private var renderLoopRunning = false
private var choreographer: Choreographer? = null
private var choreographerHandler: Handler? = null
private val frameCallback = object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
if (renderLoopRunning) {
render()
updateFpsCounter()
choreographer?.postFrameCallback(this)
}
}
}
private var frameCount = 0
private var lastFpsUpdateTime = 0L
// Reusable uniform buffer to avoid allocations per frame
private val uniformData = ByteBuffer.allocateDirect(48).order(ByteOrder.nativeOrder())
// Pre-allocated render objects to avoid per-frame allocations
private val clearColor = GPUColor(0.0, 0.0, 0.0, 0.0)
companion object {
private const val UNIFORM_BUFFER_SIZE = 48L
// Resolution scale for performance (1.0 = full, 0.5 = half resolution)
private const val RESOLUTION_SCALE = 0.6f
// WebGPU constants (raw values to avoid RestrictedApi warnings)
private const val TEXTURE_FORMAT_RGBA8_UNORM = 0x00000016
private const val TEXTURE_FORMAT_BGRA8_UNORM = 0x0000001b
private const val TEXTURE_USAGE_COPY_DST = 0x00000002
private const val TEXTURE_USAGE_TEXTURE_BINDING = 0x00000004
private const val TEXTURE_USAGE_RENDER_ATTACHMENT = 0x00000010
private const val BUFFER_USAGE_COPY_DST = 0x00000008
private const val BUFFER_USAGE_UNIFORM = 0x00000040
private const val SHADER_STAGE_VERTEX = 0x00000001
private const val SHADER_STAGE_FRAGMENT = 0x00000002
private const val BUFFER_BINDING_TYPE_UNIFORM = 0x00000002
private const val TEXTURE_SAMPLE_TYPE_FLOAT = 0x00000002
private const val TEXTURE_VIEW_DIMENSION_2D = 0x00000002
private const val TEXTURE_VIEW_DIMENSION_CUBE = 0x00000004
private const val TEXTURE_DIMENSION_2D = 0x00000002
private const val SAMPLER_BINDING_TYPE_FILTERING = 0x00000002
private const val ADDRESS_MODE_REPEAT = 0x00000002
private const val FILTER_MODE_LINEAR = 0x00000002
private const val LOAD_OP_CLEAR = 0x00000002
private const val STORE_OP_STORE = 0x00000001
// Align to 256 bytes (WebGPU requirement for bytesPerRow)
private fun alignTo256(value: Int): Int = (value + 255) and 0xFF.inv()
}
override suspend fun initialize(context: Context) {
try {
initLibrary()
instance = GPU.createInstance()
adapter = instance?.requestAdapter()
device = adapter?.requestDevice()
if (device == null) {
_error.value = "Failed to create GPU device"
return
}
loadShader(context)
createUniformBuffer()
loadNoiseTexture(context)
createCubemapTexture()
createSampler()
createBindGroupLayout()
createPipelineLayout()
startTime = System.nanoTime()
_isInitialized.value = true
} catch (e: Exception) {
_error.value = "Initialization failed: ${e.message}"
}
}
override suspend fun attachSurface(surface: Surface, width: Int, height: Int) {
val dev = device ?: return
val inst = instance ?: return
surfaceWidth = width
surfaceHeight = height
renderWidth = (width * RESOLUTION_SCALE).toInt().coerceAtLeast(1)
renderHeight = (height * RESOLUTION_SCALE).toInt().coerceAtLeast(1)
gpuSurface = inst.createSurface(
GPUSurfaceDescriptor(
surfaceSourceAndroidNativeWindow = GPUSurfaceSourceAndroidNativeWindow(
window = Util.windowFromSurface(surface)
)
)
)
val caps = gpuSurface?.getCapabilities(adapter!!)
surfaceFormat = caps?.formats?.firstOrNull() ?: TEXTURE_FORMAT_BGRA8_UNORM
gpuSurface?.configure(
GPUSurfaceConfiguration(
device = dev,
width = renderWidth,
height = renderHeight,
format = surfaceFormat,
usage = TEXTURE_USAGE_RENDER_ATTACHMENT
)
)
createRenderPipeline()
createBindGroup()
}
override fun detachSurface() {
stopRenderLoop()
gpuSurface?.unconfigure()
gpuSurface?.close()
gpuSurface = null
}
override fun updateRenderState(state: OrbRenderState) {
currentState = state
}
override fun render() {
val dev = device ?: return
val surf = gpuSurface ?: return
val pipeline = renderPipeline ?: return
val bg = bindGroup ?: return
try {
updateUniforms()
val surfaceTexture = surf.getCurrentTexture()
val textureView = surfaceTexture.texture.createView()
val commandEncoder = dev.createCommandEncoder()
val renderPass = commandEncoder.beginRenderPass(
GPURenderPassDescriptor(
colorAttachments = arrayOf(
GPURenderPassColorAttachment(
view = textureView,
loadOp = LOAD_OP_CLEAR,
storeOp = STORE_OP_STORE,
clearValue = clearColor
)
)
)
)
renderPass.setPipeline(pipeline)
renderPass.setBindGroup(0, bg)
renderPass.draw(6)
renderPass.end()
val commandBuffer = commandEncoder.finish()
dev.queue.submit(arrayOf(commandBuffer))
// Close command buffer immediately after submit - GPU has its own copy
commandBuffer.close()
commandEncoder.close()
renderPass.close()
surf.present()
textureView.close()
} catch (_: Exception) {
// Continue on error
}
}
override fun startRenderLoop() {
if (renderLoopRunning) return
renderLoopRunning = true
frameCount = 0
lastFpsUpdateTime = System.nanoTime()
choreographerHandler = Handler(Looper.getMainLooper())
choreographerHandler?.post {
choreographer = Choreographer.getInstance()
choreographer?.postFrameCallback(frameCallback)
}
}
override fun stopRenderLoop() {
renderLoopRunning = false
choreographerHandler?.post {
choreographer?.removeFrameCallback(frameCallback)
}
choreographerHandler = null
choreographer = null
}
private fun updateFpsCounter() {
frameCount++
val now = System.nanoTime()
val elapsed = now - lastFpsUpdateTime
if (elapsed >= 1_000_000_000L) {
_fps.value = (frameCount * 1_000_000_000L / elapsed).toInt()
frameCount = 0
lastFpsUpdateTime = now
}
}
override fun release() {
stopRenderLoop()
bindGroup?.close()
renderPipeline?.close()
pipelineLayout?.close()
bindGroupLayout?.close()
sampler?.close()
cubemapTextureView?.close()
cubemapTexture?.close()
noiseTextureView?.close()
noiseTexture?.close()
uniformBuffer?.close()
shaderModule?.close()
gpuSurface?.close()
device?.close()
adapter?.close()
instance?.close()
_isInitialized.value = false
}
private fun loadShader(context: Context) {
val shaderCode = context.assets.open("shaders/Orb.wgsl").bufferedReader().use {
it.readText()
}
shaderModule = device?.createShaderModule(
GPUShaderModuleDescriptor(
shaderSourceWGSL = GPUShaderSourceWGSL(code = shaderCode)
)
)
}
private fun createUniformBuffer() {
uniformBuffer = device?.createBuffer(
GPUBufferDescriptor(
usage = BUFFER_USAGE_UNIFORM or BUFFER_USAGE_COPY_DST,
size = UNIFORM_BUFFER_SIZE
)
)
}
private fun loadNoiseTexture(context: Context) {
val bitmap = context.assets.open("textures/noise_map.png").use { inputStream ->
BitmapFactory.decodeStream(inputStream)
}
val width = bitmap.width
val height = bitmap.height
val bytesPerPixel = 4
val bytesPerRow = alignTo256(width * bytesPerPixel)
noiseTexture = device?.createTexture(
GPUTextureDescriptor(
usage = TEXTURE_USAGE_TEXTURE_BINDING or TEXTURE_USAGE_COPY_DST,
size = GPUExtent3D(width, height, 1),
format = TEXTURE_FORMAT_RGBA8_UNORM
)
)
val pixels = IntArray(width * height)
bitmap.getPixels(pixels, 0, width, 0, 0, width, height)
// Create buffer with aligned row size
val buffer = ByteBuffer.allocateDirect(bytesPerRow * height).order(ByteOrder.nativeOrder())
for (y in 0 until height) {
for (x in 0 until width) {
val pixel = pixels[y * width + x]
buffer.put((pixel shr 16 and 0xFF).toByte()) // R
buffer.put((pixel shr 8 and 0xFF).toByte()) // G
buffer.put((pixel and 0xFF).toByte()) // B
buffer.put((pixel shr 24 and 0xFF).toByte()) // A
}
// Pad to aligned row size
val padding = bytesPerRow - (width * bytesPerPixel)
for (i in 0 until padding) {
buffer.put(0)
}
}
buffer.rewind()
device?.queue?.writeTexture(
destination = GPUTexelCopyTextureInfo(texture = noiseTexture!!),
data = buffer,
writeSize = GPUExtent3D(width, height, 1),
dataLayout = GPUTexelCopyBufferLayout(
offset = 0,
bytesPerRow = bytesPerRow,
rowsPerImage = height
)
)
noiseTextureView = noiseTexture?.createView()
bitmap.recycle()
}
private fun createCubemapTexture() {
val faceSize = CubemapGenerator.getFaceSize()
val bytesPerPixel = 4
val bytesPerRow = alignTo256(faceSize * bytesPerPixel)
cubemapTexture = device?.createTexture(
GPUTextureDescriptor(
usage = TEXTURE_USAGE_TEXTURE_BINDING or TEXTURE_USAGE_COPY_DST,
size = GPUExtent3D(faceSize, faceSize, 6),
format = TEXTURE_FORMAT_RGBA8_UNORM,
dimension = TEXTURE_DIMENSION_2D
)
)
val cubemapData = CubemapGenerator.generate()
val sourceBytesPerRow = faceSize * bytesPerPixel
for (face in 0 until 6) {
// Create buffer with aligned row size
val faceBuffer = ByteBuffer.allocateDirect(bytesPerRow * faceSize).order(ByteOrder.nativeOrder())
for (y in 0 until faceSize) {
// Copy one row from source
cubemapData.position(face * faceSize * sourceBytesPerRow + y * sourceBytesPerRow)
for (x in 0 until sourceBytesPerRow) {
faceBuffer.put(cubemapData.get())
}
// Pad to aligned row size
val padding = bytesPerRow - sourceBytesPerRow
for (i in 0 until padding) {
faceBuffer.put(0)
}
}
faceBuffer.rewind()
device?.queue?.writeTexture(
destination = GPUTexelCopyTextureInfo(
texture = cubemapTexture!!,
origin = GPUOrigin3D(0, 0, face)
),
data = faceBuffer,
writeSize = GPUExtent3D(faceSize, faceSize, 1),
dataLayout = GPUTexelCopyBufferLayout(
offset = 0,
bytesPerRow = bytesPerRow,
rowsPerImage = faceSize
)
)
}
cubemapTextureView = cubemapTexture?.createView(
GPUTextureViewDescriptor(
usage = TEXTURE_USAGE_TEXTURE_BINDING,
dimension = TEXTURE_VIEW_DIMENSION_CUBE,
arrayLayerCount = 6
)
)
}
private fun createSampler() {
sampler = device?.createSampler(
GPUSamplerDescriptor(
addressModeU = ADDRESS_MODE_REPEAT,
addressModeV = ADDRESS_MODE_REPEAT,
addressModeW = ADDRESS_MODE_REPEAT,
magFilter = FILTER_MODE_LINEAR,
minFilter = FILTER_MODE_LINEAR
)
)
}
private fun createBindGroupLayout() {
bindGroupLayout = device?.createBindGroupLayout(
GPUBindGroupLayoutDescriptor(
entries = arrayOf(
GPUBindGroupLayoutEntry(
binding = 0,
visibility = SHADER_STAGE_VERTEX or SHADER_STAGE_FRAGMENT,
buffer = GPUBufferBindingLayout(type = BUFFER_BINDING_TYPE_UNIFORM)
),
GPUBindGroupLayoutEntry(
binding = 1,
visibility = SHADER_STAGE_FRAGMENT,
texture = GPUTextureBindingLayout(
sampleType = TEXTURE_SAMPLE_TYPE_FLOAT,
viewDimension = TEXTURE_VIEW_DIMENSION_2D
)
),
GPUBindGroupLayoutEntry(
binding = 2,
visibility = SHADER_STAGE_FRAGMENT,
texture = GPUTextureBindingLayout(
sampleType = TEXTURE_SAMPLE_TYPE_FLOAT,
viewDimension = TEXTURE_VIEW_DIMENSION_CUBE
)
),
GPUBindGroupLayoutEntry(
binding = 3,
visibility = SHADER_STAGE_FRAGMENT,
sampler = GPUSamplerBindingLayout(type = SAMPLER_BINDING_TYPE_FILTERING)
)
)
)
)
}
private fun createPipelineLayout() {
pipelineLayout = device?.createPipelineLayout(
GPUPipelineLayoutDescriptor(bindGroupLayouts = arrayOf(bindGroupLayout!!))
)
}
private fun createRenderPipeline() {
val shader = shaderModule ?: return
val layout = pipelineLayout ?: return
renderPipeline = device?.createRenderPipeline(
GPURenderPipelineDescriptor(
layout = layout,
vertex = GPUVertexState(module = shader, entryPoint = "vs_main"),
fragment = GPUFragmentState(
module = shader,
entryPoint = "fs_main",
targets = arrayOf(GPUColorTargetState(format = surfaceFormat))
)
)
)
}
private fun createBindGroup() {
val ub = uniformBuffer ?: return
val noiseView = noiseTextureView ?: return
val cubeView = cubemapTextureView ?: return
val samp = sampler ?: return
val layout = bindGroupLayout ?: return
bindGroup = device?.createBindGroup(
GPUBindGroupDescriptor(
layout = layout,
entries = arrayOf(
GPUBindGroupEntry(binding = 0, buffer = ub, size = UNIFORM_BUFFER_SIZE),
GPUBindGroupEntry(binding = 1, textureView = noiseView),
GPUBindGroupEntry(binding = 2, textureView = cubeView),
GPUBindGroupEntry(binding = 3, sampler = samp)
)
)
)
}
private fun updateUniforms() {
val ub = uniformBuffer ?: return
val dev = device ?: return
val elapsedTime = (System.nanoTime() - startTime) / 1_000_000_000f
// Use original aspect ratio for correct projection
val aspectRatio = if (surfaceHeight > 0) surfaceWidth.toFloat() / surfaceHeight else 1f
// Reuse the pre-allocated buffer
uniformData.clear()
// resolution (vec2<f32>) - offset 0 (use render resolution)
uniformData.putFloat(renderWidth.toFloat())
uniformData.putFloat(renderHeight.toFloat())
// time (f32) - offset 8
uniformData.putFloat(elapsedTime)
// amplitude (f32) - offset 12
uniformData.putFloat(currentState.amplitude)
// color (vec4<f32>) - offset 16 (16-byte aligned)
uniformData.putFloat(currentState.color.red)
uniformData.putFloat(currentState.color.green)
uniformData.putFloat(currentState.color.blue)
uniformData.putFloat(currentState.color.alpha)
// aspectRatio (f32) - offset 32
uniformData.putFloat(aspectRatio)
// Padding to 48 bytes
uniformData.putFloat(0f)
uniformData.putFloat(0f)
uniformData.putFloat(0f)
uniformData.rewind()
dev.queue.writeBuffer(ub, 0, uniformData)
}
}

View File

@@ -0,0 +1,269 @@
package io.visus.orbis.feature.orb.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.CompositionLocalProvider
import io.visus.orbis.core.ui.components.Slider
import io.visus.orbis.core.ui.components.SliderDefaults
import io.visus.orbis.core.ui.LocalContentColor
import io.visus.orbis.core.ui.components.Icon
import io.visus.orbis.core.ui.components.IconButton
import io.visus.orbis.core.ui.components.IconButtonVariant
import io.visus.orbis.core.ui.components.ModalBottomSheet
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import io.visus.orbis.core.ui.OrbisTheme
import io.visus.orbis.core.ui.components.Text
@Composable
fun OrbScreen(
viewModel: OrbViewModel,
modifier: Modifier = Modifier
) {
val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current
var showSettingsSheet by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
viewModel.initialize(context)
}
Box(
modifier = modifier.fillMaxSize()
) {
// WebGPU Surface (background)
if (uiState.isInitialized) {
OrbSurface(
renderService = viewModel.getRenderService(),
modifier = Modifier.fillMaxSize()
)
}
// Error message
uiState.error?.let { error ->
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = error,
style = OrbisTheme.typography.body1,
color = OrbisTheme.colors.error
)
}
}
if (uiState.isInitialized) {
// FPS Counter
Text(
text = "${uiState.fps} FPS",
style = OrbisTheme.typography.label1,
color = Color.White,
modifier = Modifier
.align(Alignment.TopStart)
.statusBarsPadding()
.padding(start = 16.dp, top = 16.dp)
)
// Settings Icon
CompositionLocalProvider(LocalContentColor provides Color.White) {
IconButton(
onClick = { showSettingsSheet = true },
variant = IconButtonVariant.Ghost,
modifier = Modifier
.align(Alignment.TopEnd)
.statusBarsPadding()
.padding(end = 8.dp, top = 8.dp)
) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = "Settings"
)
}
}
}
ModalBottomSheet(
isVisible = showSettingsSheet,
onDismissRequest = { showSettingsSheet = false }
) {
ControlsPanel(
color = uiState.renderState.color,
amplitude = uiState.renderState.amplitude,
onRedChange = viewModel::updateRed,
onGreenChange = viewModel::updateGreen,
onBlueChange = viewModel::updateBlue,
onAlphaChange = viewModel::updateAlpha,
onAmplitudeChange = viewModel::updateAmplitude,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
}
}
@Composable
private fun ControlsPanel(
color: Color,
amplitude: Float,
onRedChange: (Float) -> Unit,
onGreenChange: (Float) -> Unit,
onBlueChange: (Float) -> Unit,
onAlphaChange: (Float) -> Unit,
onAmplitudeChange: (Float) -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Color",
style = OrbisTheme.typography.label1,
color = OrbisTheme.colors.text
)
Spacer(modifier = Modifier.width(12.dp))
Box(
modifier = Modifier
.size(32.dp)
.clip(RoundedCornerShape(6.dp))
.background(color)
.border(
width = 1.dp,
color = OrbisTheme.colors.outline,
shape = RoundedCornerShape(6.dp)
)
)
}
Spacer(modifier = Modifier.height(12.dp))
// RGBA Sliders
ColorSlider(
label = "R",
value = color.red,
onValueChange = onRedChange,
trackColor = Color.Red.copy(alpha = 0.3f),
thumbColor = Color.Red
)
ColorSlider(
label = "G",
value = color.green,
onValueChange = onGreenChange,
trackColor = Color.Green.copy(alpha = 0.3f),
thumbColor = Color.Green
)
ColorSlider(
label = "B",
value = color.blue,
onValueChange = onBlueChange,
trackColor = Color.Blue.copy(alpha = 0.3f),
thumbColor = Color.Blue
)
ColorSlider(
label = "A",
value = color.alpha,
onValueChange = onAlphaChange,
trackColor = OrbisTheme.colors.outline,
thumbColor = OrbisTheme.colors.primary
)
Spacer(modifier = Modifier.height(8.dp))
// Amplitude slider
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Amp",
style = OrbisTheme.typography.label2,
color = OrbisTheme.colors.textSecondary,
modifier = Modifier.width(32.dp)
)
Slider(
value = amplitude,
onValueChange = onAmplitudeChange,
valueRange = 0f..2f,
colors = SliderDefaults.colors(
thumbColor = OrbisTheme.colors.primary,
activeTrackColor = OrbisTheme.colors.primary,
inactiveTrackColor = OrbisTheme.colors.outline
),
modifier = Modifier.weight(1f)
)
Text(
text = "${(amplitude * 100).toInt()}%",
style = OrbisTheme.typography.label2,
color = OrbisTheme.colors.textSecondary,
modifier = Modifier.width(48.dp)
)
}
}
}
@Composable
private fun ColorSlider(
label: String,
value: Float,
onValueChange: (Float) -> Unit,
trackColor: Color,
thumbColor: Color,
modifier: Modifier = Modifier
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.fillMaxWidth()
) {
Text(
text = label,
style = OrbisTheme.typography.label2,
color = OrbisTheme.colors.textSecondary,
modifier = Modifier.width(32.dp)
)
Slider(
value = value,
onValueChange = onValueChange,
valueRange = 0f..1f,
colors = SliderDefaults.colors(
thumbColor = thumbColor,
activeTrackColor = thumbColor,
inactiveTrackColor = trackColor
),
modifier = Modifier.weight(1f)
)
Text(
text = "${(value * 255).toInt()}",
style = OrbisTheme.typography.label2,
color = OrbisTheme.colors.textSecondary,
modifier = Modifier.width(48.dp)
)
}
}

View File

@@ -0,0 +1,56 @@
package io.visus.orbis.feature.orb.ui
import android.view.SurfaceHolder
import android.view.SurfaceView
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import io.visus.orbis.feature.orb.service.OrbRenderService
import kotlinx.coroutines.launch
@Composable
fun OrbSurface(
renderService: OrbRenderService,
modifier: Modifier = Modifier
) {
val coroutineScope = rememberCoroutineScope()
DisposableEffect(renderService) {
onDispose {
renderService.stopRenderLoop()
renderService.detachSurface()
}
}
AndroidView(
factory = { context ->
SurfaceView(context).apply {
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
// Surface created but dimensions may not be ready
}
override fun surfaceChanged(
holder: SurfaceHolder,
format: Int,
width: Int,
height: Int
) {
coroutineScope.launch {
renderService.attachSurface(holder.surface, width, height)
renderService.startRenderLoop()
}
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
renderService.stopRenderLoop()
renderService.detachSurface()
}
})
}
},
modifier = modifier
)
}

View File

@@ -0,0 +1,159 @@
package io.visus.orbis.feature.orb.ui
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.visus.orbis.feature.orb.data.OrbUiState
import io.visus.orbis.feature.orb.service.OrbRenderService
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/**
* ViewModel for managing the Orb feature's UI state and render service interactions.
*
* @property renderService The service responsible for handling orb rendering operations.
*/
class OrbViewModel(
private val renderService: OrbRenderService
) : ViewModel() {
private val _uiState = MutableStateFlow(OrbUiState())
/**
* Observable UI state containing the current state of the orb feature.
*/
val uiState: StateFlow<OrbUiState> = _uiState.asStateFlow()
init {
// Observe render service state
viewModelScope.launch {
renderService.isInitialized.collectLatest { initialized ->
_uiState.update { it.copy(isInitialized = initialized) }
}
}
viewModelScope.launch {
renderService.error.collectLatest { error ->
_uiState.update { it.copy(error = error) }
}
}
viewModelScope.launch {
renderService.fps.collectLatest { fps ->
_uiState.update { it.copy(fps = fps) }
}
}
}
/**
* Initializes the orb render service with the provided context.
*
* @param context The Android context required for service initialization.
*/
fun initialize(context: Context) {
viewModelScope.launch {
renderService.initialize(context)
}
}
/**
* Updates the orb's amplitude.
*
* @param amplitude The new amplitude value.
*/
fun updateAmplitude(amplitude: Float) {
_uiState.update { state ->
val newRenderState = state.renderState.copy(amplitude = amplitude)
renderService.updateRenderState(newRenderState)
state.copy(renderState = newRenderState)
}
}
/**
* Updates the red component of the orb's color.
*
* @param red The new red value (0.0 to 1.0).
*/
fun updateRed(red: Float) {
_uiState.update { state ->
val currentColor = state.renderState.color
val newColor = currentColor.copy(red = red)
val newRenderState = state.renderState.copy(color = newColor)
renderService.updateRenderState(newRenderState)
state.copy(renderState = newRenderState)
}
}
/**
* Updates the green component of the orb's color.
*
* @param green The new green value (0.0 to 1.0).
*/
fun updateGreen(green: Float) {
_uiState.update { state ->
val currentColor = state.renderState.color
val newColor = currentColor.copy(green = green)
val newRenderState = state.renderState.copy(color = newColor)
renderService.updateRenderState(newRenderState)
state.copy(renderState = newRenderState)
}
}
/**
* Updates the blue component of the orb's color.
*
* @param blue The new blue value (0.0 to 1.0).
*/
fun updateBlue(blue: Float) {
_uiState.update { state ->
val currentColor = state.renderState.color
val newColor = currentColor.copy(blue = blue)
val newRenderState = state.renderState.copy(color = newColor)
renderService.updateRenderState(newRenderState)
state.copy(renderState = newRenderState)
}
}
/**
* Updates the alpha (transparency) component of the orb's color.
*
* @param alpha The new alpha value (0.0 to 1.0).
*/
fun updateAlpha(alpha: Float) {
_uiState.update { state ->
val currentColor = state.renderState.color
val newColor = currentColor.copy(alpha = alpha)
val newRenderState = state.renderState.copy(color = newColor)
renderService.updateRenderState(newRenderState)
state.copy(renderState = newRenderState)
}
}
/**
* Returns the underlying render service instance.
*
* @return The [OrbRenderService] used by this ViewModel.
*/
fun getRenderService(): OrbRenderService = renderService
/**
* Called when the ViewModel is cleared. Releases render service resources.
*/
override fun onCleared() {
super.onCleared()
renderService.release()
}
}

View File

@@ -0,0 +1,190 @@
package io.visus.orbis.feature.orb.util
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.sqrt
/**
* Generates procedural sky cubemaps for environment mapping and reflections.
*
* This generator creates a 6-face cubemap texture representing a simple sky gradient
* that transitions from a blue sky at the top, through a bright horizon, down to a
* dark ground color. The cubemap can be used for:
* - Environment reflections on glossy surfaces
* - Skybox rendering
* - Image-based lighting approximations
*
* The generated cubemap uses RGBA8 format with faces ordered as: +X, -X, +Y, -Y, +Z, -Z
* following the standard cubemap convention.
*
* @see generate
*/
object CubemapGenerator {
/** Resolution of each cubemap face in pixels (width and height). */
private const val FACE_SIZE = 64
/** Bytes per pixel in RGBA8 format. */
private const val BYTES_PER_PIXEL = 4
// Sky color constants (stored individually to avoid array access overhead)
/** Red component of the sky color at zenith (top of sky dome). */
private const val SKY_TOP_R = 0.4f
/** Green component of the sky color at zenith. */
private const val SKY_TOP_G = 0.6f
/** Blue component of the sky color at zenith. */
private const val SKY_TOP_B = 0.9f
/** Red component of the horizon color. */
private const val SKY_HORIZON_R = 0.8f
/** Green component of the horizon color. */
private const val SKY_HORIZON_G = 0.85f
/** Blue component of the horizon color. */
private const val SKY_HORIZON_B = 0.95f
/** Red component of the ground color (below horizon). */
private const val GROUND_R = 0.15f
/** Green component of the ground color. */
private const val GROUND_G = 0.12f
/** Blue component of the ground color. */
private const val GROUND_B = 0.1f
/**
* Generates a procedural sky cubemap with 6 faces.
*
* The returned buffer contains pixel data for all 6 faces laid out sequentially
* in the standard cubemap order: +X, -X, +Y, -Y, +Z, -Z. Each face is [FACE_SIZE]
* pixels square, with 4 bytes per pixel (RGBA8 format).
*
* The buffer is allocated as a direct ByteBuffer with native byte order for
* efficient upload to GPU textures.
*
* @return A [ByteBuffer] positioned at the start, containing the complete cubemap
* data. Total size is `FACE_SIZE * FACE_SIZE * 4 * 6` bytes.
*/
fun generate(): ByteBuffer {
val totalSize = FACE_SIZE * FACE_SIZE * BYTES_PER_PIXEL * 6
val buffer = ByteBuffer.allocateDirect(totalSize).order(ByteOrder.nativeOrder())
// Pre-allocate reusable arrays to avoid per-pixel allocations
val direction = FloatArray(3)
val color = FloatArray(4)
for (face in 0 until 6) {
generateFace(buffer, face, direction, color)
}
buffer.rewind()
return buffer
}
/**
* Generates pixel data for a single cubemap face.
*
* Iterates over all pixels in the face, converts each pixel coordinate to a 3D
* direction vector, samples the sky color for that direction, and writes the
* resulting RGBA values to the buffer.
*
* @param buffer The destination buffer to write pixel data to.
* @param face The face index (0-5) corresponding to +X, -X, +Y, -Y, +Z, -Z.
* @param direction Reusable array for storing the computed direction vector.
* @param color Reusable array for storing the sampled RGBA color.
*/
private fun generateFace(buffer: ByteBuffer, face: Int, direction: FloatArray, color: FloatArray) {
for (y in 0 until FACE_SIZE) {
for (x in 0 until FACE_SIZE) {
// Convert pixel coordinates to direction vector
val u = (x + 0.5f) / FACE_SIZE * 2.0f - 1.0f
val v = (y + 0.5f) / FACE_SIZE * 2.0f - 1.0f
faceToDirection(face, u, v, direction)
sampleSky(direction, color)
// Write RGBA bytes (no coerceIn needed - colors are already in valid 0-1 range)
buffer.put((color[0] * 255f).toInt().toByte())
buffer.put((color[1] * 255f).toInt().toByte())
buffer.put((color[2] * 255f).toInt().toByte())
buffer.put((color[3] * 255f).toInt().toByte())
}
}
}
/**
* Converts 2D face coordinates to a 3D direction vector.
*
* Maps normalized UV coordinates on a cubemap face to the corresponding
* world-space direction vector pointing outward from the cube center.
*
* @param face The face index (0-5): 0=+X, 1=-X, 2=+Y, 3=-Y, 4=+Z, 5=-Z.
* @param u Horizontal coordinate in range [-1, 1], left to right.
* @param v Vertical coordinate in range [-1, 1], bottom to top.
* @param out Output array to store the normalized direction vector [x, y, z].
*/
private fun faceToDirection(face: Int, u: Float, v: Float, out: FloatArray) {
// Cubemap face ordering: +X, -X, +Y, -Y, +Z, -Z
when (face) {
0 -> { out[0] = 1.0f; out[1] = -v; out[2] = -u } // +X
1 -> { out[0] = -1.0f; out[1] = -v; out[2] = u } // -X
2 -> { out[0] = u; out[1] = 1.0f; out[2] = v } // +Y (top)
3 -> { out[0] = u; out[1] = -1.0f; out[2] = -v } // -Y (bottom)
4 -> { out[0] = u; out[1] = -v; out[2] = 1.0f } // +Z
5 -> { out[0] = -u; out[1] = -v; out[2] = -1.0f } // -Z
}
normalize(out)
}
/**
* Normalizes a 3D vector in-place to unit length.
*
* @param v The vector to normalize, modified in-place. Must have at least 3 elements.
*/
private fun normalize(v: FloatArray) {
val lenSq = v[0] * v[0] + v[1] * v[1] + v[2] * v[2]
if (lenSq > 0f) {
val invLen = (1.0 / sqrt(lenSq.toDouble())).toFloat()
v[0] *= invLen
v[1] *= invLen
v[2] *= invLen
}
}
/**
* Samples the sky color for a given direction vector.
*
* Uses the Y component of the direction to determine vertical position:
* - Positive Y (above horizon): Interpolates from horizon color to sky top color
* - Negative Y (below horizon): Interpolates from horizon color to ground color
*
* This creates a simple gradient sky with a bright horizon that fades to
* a deeper blue at the zenith and a dark ground below.
*
* @param direction The normalized direction vector to sample.
* @param out Output array to store the RGBA color values in range [0, 1].
*/
private fun sampleSky(direction: FloatArray, out: FloatArray) {
val y = direction[1]
if (y > 0f) {
// Above horizon: interpolate from horizon to sky top
val t = if (y > 1f) 1f else y
out[0] = SKY_HORIZON_R + (SKY_TOP_R - SKY_HORIZON_R) * t
out[1] = SKY_HORIZON_G + (SKY_TOP_G - SKY_HORIZON_G) * t
out[2] = SKY_HORIZON_B + (SKY_TOP_B - SKY_HORIZON_B) * t
out[3] = 1f
} else {
// Below horizon: interpolate from horizon to ground
val t = if (y < -1f) 1f else -y
out[0] = SKY_HORIZON_R + (GROUND_R - SKY_HORIZON_R) * t
out[1] = SKY_HORIZON_G + (GROUND_G - SKY_HORIZON_G) * t
out[2] = SKY_HORIZON_B + (GROUND_B - SKY_HORIZON_B) * t
out[3] = 1f
}
}
/**
* Returns the resolution (width and height) of each cubemap face in pixels.
*
* @return The size of each square cubemap face.
*/
fun getFaceSize(): Int = FACE_SIZE
}

View File

@@ -13,6 +13,7 @@ appcompat = "1.7.1"
material = "1.13.0" material = "1.13.0"
lumoVersion = "1.2.5" lumoVersion = "1.2.5"
webgpu = "1.0.0-alpha03" webgpu = "1.0.0-alpha03"
nomanrComposables = "1.1.1"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -33,6 +34,7 @@ androidx-compose-material = { group = "androidx.compose.material", name = "mater
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
androidx-compose-animation = { group = "androidx.compose.animation", name = "animation" } androidx-compose-animation = { group = "androidx.compose.animation", name = "animation" }
androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" } androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" }
androidx-compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" } material = { group = "com.google.android.material", name = "material", version.ref = "material" }
koin-bom = { group = "io.insert-koin", name = "koin-bom", version.ref = "koin" } koin-bom = { group = "io.insert-koin", name = "koin-bom", version.ref = "koin" }
@@ -41,6 +43,7 @@ koin-android = { group = "io.insert-koin", name = "koin-android" }
koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose" } koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose" }
koin-androidx-compose-navigation = { group = "io.insert-koin", name = "koin-androidx-compose-navigation" } koin-androidx-compose-navigation = { group = "io.insert-koin", name = "koin-androidx-compose-navigation" }
androidx-webgpu = { group = "androidx.webgpu", name = "webgpu", version.ref = "webgpu" } androidx-webgpu = { group = "androidx.webgpu", name = "webgpu", version.ref = "webgpu" }
nomanr-composables = { group = "com.nomanr", name = "composables", version.ref = "nomanrComposables" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }

View File

@@ -22,6 +22,4 @@ dependencyResolutionManagement {
rootProject.name = "Orbis" rootProject.name = "Orbis"
include(":app") include(":app")
include(":core:ui") include(":core:ui")
include(":core:domain")
include(":core:data")
include(":feature:orb") include(":feature:orb")