feat(orb): implement orb animation
Signed-off-by: Alan Brault <alan.brault@visus.io>
This commit is contained in:
80
CLAUDE.md
Normal file
80
CLAUDE.md
Normal 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
|
||||
@@ -40,8 +40,6 @@ android {
|
||||
dependencies {
|
||||
// Project modules
|
||||
implementation(project(":core:ui"))
|
||||
implementation(project(":core:domain"))
|
||||
implementation(project(":core:data"))
|
||||
implementation(project(":feature:orb"))
|
||||
|
||||
// AndroidX
|
||||
|
||||
@@ -5,12 +5,12 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import io.visus.orbis.core.ui.OrbisTheme
|
||||
import io.visus.orbis.ui.main.MainScreen
|
||||
import io.visus.orbis.ui.main.MainViewModel
|
||||
import io.visus.orbis.feature.orb.ui.OrbScreen
|
||||
import io.visus.orbis.feature.orb.ui.OrbViewModel
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val viewModel: MainViewModel by viewModel()
|
||||
private val orbViewModel: OrbViewModel by viewModel()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -18,7 +18,7 @@ class MainActivity : ComponentActivity() {
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
OrbisTheme {
|
||||
MainScreen(viewModel = viewModel)
|
||||
OrbScreen(viewModel = orbViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package io.visus.orbis.di
|
||||
|
||||
import io.visus.orbis.ui.main.MainViewModel
|
||||
import org.koin.core.module.dsl.viewModelOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
val appModule = module {
|
||||
viewModelOf(::MainViewModel)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
1
core/data/.gitignore
vendored
1
core/data/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
/build
|
||||
@@ -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)
|
||||
}
|
||||
21
core/data/proguard-rules.pro
vendored
21
core/data/proguard-rules.pro
vendored
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
</manifest>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
1
core/domain/.gitignore
vendored
1
core/domain/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
/build
|
||||
@@ -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)
|
||||
}
|
||||
21
core/domain/proguard-rules.pro
vendored
21
core/domain/proguard-rules.pro
vendored
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
</manifest>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -45,11 +45,15 @@ dependencies {
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.foundation)
|
||||
implementation(libs.androidx.compose.animation)
|
||||
implementation(libs.androidx.compose.material)
|
||||
implementation(libs.androidx.compose.runtime)
|
||||
|
||||
// Composables (bottom sheet, slider)
|
||||
api(libs.nomanr.composables)
|
||||
|
||||
// Testing
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
|
||||
@@ -3,6 +3,7 @@ package io.visus.orbis.core.ui
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Black: Color = Color(0xFF000000)
|
||||
@@ -52,6 +53,9 @@ val Green200: Color = Color(0xFFC2F5DA)
|
||||
val Green100: Color = Color(0xFFD0FBE9)
|
||||
val Green50: Color = Color(0xFFE0FAEC)
|
||||
|
||||
val NavyBlue900: Color = Color(0xFF00002E)
|
||||
val NavyBlue800: Color = Color(0xFF1A1A47)
|
||||
|
||||
@Immutable
|
||||
data class Colors(
|
||||
val primary: Color,
|
||||
@@ -79,6 +83,7 @@ data class Colors(
|
||||
val textDisabled: Color,
|
||||
val scrim: Color,
|
||||
val elevation: Color,
|
||||
val defaultBackgroundGradient: Brush,
|
||||
)
|
||||
|
||||
internal val LightColors =
|
||||
@@ -108,6 +113,9 @@ internal val LightColors =
|
||||
textDisabled = Gray400,
|
||||
scrim = Color.Black.copy(alpha = 0.32f),
|
||||
elevation = Gray700,
|
||||
defaultBackgroundGradient = Brush.verticalGradient(
|
||||
colors = listOf(NavyBlue800, NavyBlue900, Black)
|
||||
),
|
||||
)
|
||||
|
||||
internal val DarkColors =
|
||||
@@ -137,6 +145,9 @@ internal val DarkColors =
|
||||
textDisabled = Gray600,
|
||||
scrim = Color.Black.copy(alpha = 0.72f),
|
||||
elevation = Gray200,
|
||||
defaultBackgroundGradient = Brush.verticalGradient(
|
||||
colors = listOf(NavyBlue800, NavyBlue900, Black)
|
||||
),
|
||||
)
|
||||
|
||||
val LocalColors = staticCompositionLocalOf { LightColors }
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,6 @@ android {
|
||||
dependencies {
|
||||
// Project modules
|
||||
implementation(project(":core:ui"))
|
||||
implementation(project(":core:domain"))
|
||||
|
||||
// AndroidX
|
||||
implementation(libs.androidx.core.ktx)
|
||||
@@ -49,6 +48,7 @@ dependencies {
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.material.icons)
|
||||
implementation(libs.androidx.compose.foundation)
|
||||
implementation(libs.androidx.compose.runtime)
|
||||
|
||||
|
||||
@@ -22,11 +22,21 @@
|
||||
// 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
|
||||
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
|
||||
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)
|
||||
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`.
|
||||
// Combines a base sphere with animated noise displacement.
|
||||
fn map(p: vec3<f32>) -> f32 {
|
||||
// Base UV from world position (scaled down for tiling)
|
||||
let u = p.xy * 0.2;
|
||||
// Animated UV for displacement (single sample for mobile performance)
|
||||
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
|
||||
// Creates slow, flowing motion across the surface
|
||||
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
|
||||
// Single noise sample (mobile optimization - was 2 samples)
|
||||
let noise = textureSampleLevel(noiseTexture, texSampler, um, 0.0).x;
|
||||
|
||||
// Sample noise at two frequencies:
|
||||
// - 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
|
||||
// Apply displacement with amplitude control
|
||||
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;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 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
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -162,15 +188,32 @@ fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
|
||||
// -------------------------
|
||||
// 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
|
||||
|
||||
// -------------------------
|
||||
// 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
|
||||
// -------------------------
|
||||
var pos = campos;
|
||||
var tdist: f32 = 0.0; // Total distance traveled
|
||||
var dist: f32 = EPS; // Current step distance
|
||||
// Start ray at intersection with bounding sphere for faster convergence
|
||||
let tstart = max(0.0, -b - sqrt(discriminant));
|
||||
var pos = campos + tstart * raydir;
|
||||
var tdist: f32 = tstart;
|
||||
var dist: f32 = EPS;
|
||||
|
||||
for (var i: i32 = 0; i < MAX_ITR; i = i + 1) {
|
||||
if (dist < EPS || tdist > MAX_DIS) {
|
||||
@@ -185,13 +228,15 @@ fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
|
||||
// Shading (on hit)
|
||||
// -------------------------
|
||||
if (dist < EPS) {
|
||||
// Compute surface normal via central differences
|
||||
let eps = vec2<f32>(0.0, EPS);
|
||||
let normal = normalize(vec3<f32>(
|
||||
map(pos + eps.yxx) - map(pos - eps.yxx),
|
||||
map(pos + eps.xyx) - map(pos - eps.xyx),
|
||||
map(pos + eps.xxy) - map(pos - eps.xxy)
|
||||
));
|
||||
// Compute surface normal via tetrahedral sampling (4 samples instead of 6)
|
||||
// More efficient than central differences while maintaining quality
|
||||
let e = vec2<f32>(1.0, -1.0) * 0.5773 * EPS;
|
||||
let normal = normalize(
|
||||
e.xyy * map(pos + e.xyy) +
|
||||
e.yyx * map(pos + e.yyx) +
|
||||
e.yxy * map(pos + e.yxy) +
|
||||
e.xxx * map(pos + e.xxx)
|
||||
);
|
||||
|
||||
// Diffuse lighting (Lambertian)
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -1,6 +1,14 @@
|
||||
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
|
||||
|
||||
val orbModule = module {
|
||||
singleOf(::OrbRenderServiceImpl) { bind<OrbRenderService>() }
|
||||
viewModelOf(::OrbViewModel)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -13,6 +13,7 @@ appcompat = "1.7.1"
|
||||
material = "1.13.0"
|
||||
lumoVersion = "1.2.5"
|
||||
webgpu = "1.0.0-alpha03"
|
||||
nomanrComposables = "1.1.1"
|
||||
|
||||
[libraries]
|
||||
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-animation = { group = "androidx.compose.animation", name = "animation" }
|
||||
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" }
|
||||
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
||||
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-navigation = { group = "io.insert-koin", name = "koin-androidx-compose-navigation" }
|
||||
androidx-webgpu = { group = "androidx.webgpu", name = "webgpu", version.ref = "webgpu" }
|
||||
nomanr-composables = { group = "com.nomanr", name = "composables", version.ref = "nomanrComposables" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
@@ -22,6 +22,4 @@ dependencyResolutionManagement {
|
||||
rootProject.name = "Orbis"
|
||||
include(":app")
|
||||
include(":core:ui")
|
||||
include(":core:domain")
|
||||
include(":core:data")
|
||||
include(":feature:orb")
|
||||
|
||||
Reference in New Issue
Block a user