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 {
|
dependencies {
|
||||||
// Project modules
|
// Project modules
|
||||||
implementation(project(":core:ui"))
|
implementation(project(":core:ui"))
|
||||||
implementation(project(":core:domain"))
|
|
||||||
implementation(project(":core:data"))
|
|
||||||
implementation(project(":feature:orb"))
|
implementation(project(":feature:orb"))
|
||||||
|
|
||||||
// AndroidX
|
// AndroidX
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import androidx.activity.ComponentActivity
|
|||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import io.visus.orbis.core.ui.OrbisTheme
|
import io.visus.orbis.core.ui.OrbisTheme
|
||||||
import io.visus.orbis.ui.main.MainScreen
|
import io.visus.orbis.feature.orb.ui.OrbScreen
|
||||||
import io.visus.orbis.ui.main.MainViewModel
|
import io.visus.orbis.feature.orb.ui.OrbViewModel
|
||||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private val viewModel: MainViewModel by viewModel()
|
private val orbViewModel: OrbViewModel by viewModel()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@@ -18,7 +18,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
OrbisTheme {
|
OrbisTheme {
|
||||||
MainScreen(viewModel = viewModel)
|
OrbScreen(viewModel = orbViewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
package io.visus.orbis.di
|
package io.visus.orbis.di
|
||||||
|
|
||||||
import io.visus.orbis.ui.main.MainViewModel
|
|
||||||
import org.koin.core.module.dsl.viewModelOf
|
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
val appModule = module {
|
val appModule = module {
|
||||||
viewModelOf(::MainViewModel)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.androidx.compose.ui)
|
implementation(libs.androidx.compose.ui)
|
||||||
implementation(libs.androidx.compose.ui.graphics)
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
implementation(libs.androidx.compose.foundation)
|
implementation(libs.androidx.compose.foundation)
|
||||||
implementation(libs.androidx.compose.animation)
|
implementation(libs.androidx.compose.animation)
|
||||||
implementation(libs.androidx.compose.material)
|
implementation(libs.androidx.compose.material)
|
||||||
implementation(libs.androidx.compose.runtime)
|
implementation(libs.androidx.compose.runtime)
|
||||||
|
|
||||||
|
// Composables (bottom sheet, slider)
|
||||||
|
api(libs.nomanr.composables)
|
||||||
|
|
||||||
// Testing
|
// Testing
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package io.visus.orbis.core.ui
|
|||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
val Black: Color = Color(0xFF000000)
|
val Black: Color = Color(0xFF000000)
|
||||||
@@ -52,6 +53,9 @@ val Green200: Color = Color(0xFFC2F5DA)
|
|||||||
val Green100: Color = Color(0xFFD0FBE9)
|
val Green100: Color = Color(0xFFD0FBE9)
|
||||||
val Green50: Color = Color(0xFFE0FAEC)
|
val Green50: Color = Color(0xFFE0FAEC)
|
||||||
|
|
||||||
|
val NavyBlue900: Color = Color(0xFF00002E)
|
||||||
|
val NavyBlue800: Color = Color(0xFF1A1A47)
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
data class Colors(
|
data class Colors(
|
||||||
val primary: Color,
|
val primary: Color,
|
||||||
@@ -79,6 +83,7 @@ data class Colors(
|
|||||||
val textDisabled: Color,
|
val textDisabled: Color,
|
||||||
val scrim: Color,
|
val scrim: Color,
|
||||||
val elevation: Color,
|
val elevation: Color,
|
||||||
|
val defaultBackgroundGradient: Brush,
|
||||||
)
|
)
|
||||||
|
|
||||||
internal val LightColors =
|
internal val LightColors =
|
||||||
@@ -108,6 +113,9 @@ internal val LightColors =
|
|||||||
textDisabled = Gray400,
|
textDisabled = Gray400,
|
||||||
scrim = Color.Black.copy(alpha = 0.32f),
|
scrim = Color.Black.copy(alpha = 0.32f),
|
||||||
elevation = Gray700,
|
elevation = Gray700,
|
||||||
|
defaultBackgroundGradient = Brush.verticalGradient(
|
||||||
|
colors = listOf(NavyBlue800, NavyBlue900, Black)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
internal val DarkColors =
|
internal val DarkColors =
|
||||||
@@ -137,6 +145,9 @@ internal val DarkColors =
|
|||||||
textDisabled = Gray600,
|
textDisabled = Gray600,
|
||||||
scrim = Color.Black.copy(alpha = 0.72f),
|
scrim = Color.Black.copy(alpha = 0.72f),
|
||||||
elevation = Gray200,
|
elevation = Gray200,
|
||||||
|
defaultBackgroundGradient = Brush.verticalGradient(
|
||||||
|
colors = listOf(NavyBlue800, NavyBlue900, Black)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
val LocalColors = staticCompositionLocalOf { LightColors }
|
val LocalColors = staticCompositionLocalOf { LightColors }
|
||||||
|
|||||||
@@ -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 {
|
dependencies {
|
||||||
// Project modules
|
// Project modules
|
||||||
implementation(project(":core:ui"))
|
implementation(project(":core:ui"))
|
||||||
implementation(project(":core:domain"))
|
|
||||||
|
|
||||||
// AndroidX
|
// AndroidX
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
@@ -49,6 +48,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.compose.ui.graphics)
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
implementation(libs.androidx.compose.material3)
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation(libs.androidx.compose.material.icons)
|
||||||
implementation(libs.androidx.compose.foundation)
|
implementation(libs.androidx.compose.foundation)
|
||||||
implementation(libs.androidx.compose.runtime)
|
implementation(libs.androidx.compose.runtime)
|
||||||
|
|
||||||
|
|||||||
@@ -22,11 +22,21 @@
|
|||||||
// Constants
|
// Constants
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Background radial gradient colors (light gray center to darker gray edge)
|
||||||
|
// #d0d0d0 at center (0%)
|
||||||
|
const GRADIENT_CENTER: vec3<f32> = vec3<f32>(0.816, 0.816, 0.816);
|
||||||
|
// #a8a8a8 at 70%
|
||||||
|
const GRADIENT_MID: vec3<f32> = vec3<f32>(0.659, 0.659, 0.659);
|
||||||
|
// #909090 at edge (100%)
|
||||||
|
const GRADIENT_EDGE: vec3<f32> = vec3<f32>(0.565, 0.565, 0.565);
|
||||||
|
|
||||||
// Surface intersection threshold - rays closer than this are considered hits
|
// Surface intersection threshold - rays closer than this are considered hits
|
||||||
const EPS: f32 = 0.01;
|
// Larger value = faster convergence but less precision (mobile optimization)
|
||||||
|
const EPS: f32 = 0.04;
|
||||||
|
|
||||||
// Maximum raymarching iterations to prevent infinite loops
|
// Maximum raymarching iterations to prevent infinite loops
|
||||||
const MAX_ITR: i32 = 100;
|
// Reduced from 100 for mobile GPU performance
|
||||||
|
const MAX_ITR: i32 = 32;
|
||||||
|
|
||||||
// Maximum ray travel distance before giving up (ray miss)
|
// Maximum ray travel distance before giving up (ray miss)
|
||||||
const MAX_DIS: f32 = 10.0;
|
const MAX_DIS: f32 = 10.0;
|
||||||
@@ -104,31 +114,47 @@ fn sd_sph(p: vec3<f32>, r: f32) -> f32 {
|
|||||||
// Evaluates the distance field at point `p`.
|
// Evaluates the distance field at point `p`.
|
||||||
// Combines a base sphere with animated noise displacement.
|
// Combines a base sphere with animated noise displacement.
|
||||||
fn map(p: vec3<f32>) -> f32 {
|
fn map(p: vec3<f32>) -> f32 {
|
||||||
// Base UV from world position (scaled down for tiling)
|
// Animated UV for displacement (single sample for mobile performance)
|
||||||
let u = p.xy * 0.2;
|
var um = p.xy * 0.06;
|
||||||
|
um.x = um.x + uniforms.time * 0.1;
|
||||||
|
um.y = um.y - uniforms.time * 0.025;
|
||||||
|
um.x = um.x + um.y * 2.0;
|
||||||
|
|
||||||
// Animated UV for large-scale displacement
|
// Single noise sample (mobile optimization - was 2 samples)
|
||||||
// Creates slow, flowing motion across the surface
|
let noise = textureSampleLevel(noiseTexture, texSampler, um, 0.0).x;
|
||||||
var um = u * 0.3;
|
|
||||||
um.x = um.x + uniforms.time * 0.1; // Horizontal drift
|
|
||||||
um.y = um.y - uniforms.time * 0.025; // Slower vertical drift
|
|
||||||
um.x = um.x + um.y * 2.0; // Shear for diagonal motion
|
|
||||||
|
|
||||||
// Sample noise at two frequencies:
|
// Apply displacement with amplitude control
|
||||||
// - hlg: Large-scale, animated features (the "goo" blobs)
|
|
||||||
// - hfn: Fine detail, static relative to surface
|
|
||||||
let hlg = textureSampleLevel(noiseTexture, texSampler, um, 0.0).x;
|
|
||||||
let hfn = textureSampleLevel(noiseTexture, texSampler, u, 0.0).x;
|
|
||||||
|
|
||||||
// Combine noise samples with amplitude control
|
|
||||||
// Fine detail is reduced where large features are prominent
|
|
||||||
let amp = max(uniforms.amplitude, 0.15);
|
let amp = max(uniforms.amplitude, 0.15);
|
||||||
let disp = (hlg * 0.4 + hfn * 0.1 * (1.0 - hlg)) * amp;
|
let disp = noise * 0.5 * amp;
|
||||||
|
|
||||||
// Return displaced sphere distance
|
|
||||||
return sd_sph(p, 1.5) + disp;
|
return sd_sph(p, 1.5) + disp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Background Gradient
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Computes the radial gradient background color based on distance from center.
|
||||||
|
// Gradient: #d0d0d0 (center) -> #a8a8a8 (70%) -> #909090 (edge)
|
||||||
|
fn gradient_background(uv: vec2<f32>, aspect: f32) -> vec3<f32> {
|
||||||
|
// Center the UV coordinates
|
||||||
|
var centered = uv - vec2<f32>(0.5, 0.5);
|
||||||
|
// Apply aspect ratio correction so gradient is circular not elliptical
|
||||||
|
centered.x = centered.x * aspect;
|
||||||
|
// Distance from center (0 at center, ~0.5-0.7 at edges depending on aspect)
|
||||||
|
let dist = length(centered) * 2.0; // Scale so edges are roughly 1.0
|
||||||
|
|
||||||
|
if (dist < 0.7) {
|
||||||
|
// Center to 70%: blend from CENTER to MID
|
||||||
|
let t = dist / 0.7;
|
||||||
|
return mix(GRADIENT_CENTER, GRADIENT_MID, t);
|
||||||
|
} else {
|
||||||
|
// 70% to edge: blend from MID to EDGE
|
||||||
|
let t = min((dist - 0.7) / 0.3, 1.0);
|
||||||
|
return mix(GRADIENT_MID, GRADIENT_EDGE, t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// Lighting Utilities
|
// Lighting Utilities
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -162,15 +188,32 @@ fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
|
|||||||
// -------------------------
|
// -------------------------
|
||||||
// Camera Setup
|
// Camera Setup
|
||||||
// -------------------------
|
// -------------------------
|
||||||
let campos = vec3<f32>(0.0, 0.0, -2.9); // Camera position (in front of sphere)
|
let campos = vec3<f32>(0.0, 0.0, -5.5); // Camera position (moved back for smaller orb)
|
||||||
let raydir = normalize(vec3<f32>(d.x, -d.y, 1.0)); // Ray direction per pixel
|
let raydir = normalize(vec3<f32>(d.x, -d.y, 1.0)); // Ray direction per pixel
|
||||||
|
|
||||||
|
// -------------------------
|
||||||
|
// Early-out: Skip rays that clearly miss the sphere
|
||||||
|
// -------------------------
|
||||||
|
// Ray-sphere intersection test for bounding sphere (radius 2.0 to account for displacement)
|
||||||
|
let oc = campos; // origin to center (center is at origin)
|
||||||
|
let b = dot(oc, raydir);
|
||||||
|
let c = dot(oc, oc) - 4.0; // 4.0 = 2.0^2 (bounding radius)
|
||||||
|
let discriminant = b * b - c;
|
||||||
|
|
||||||
|
// If ray misses bounding sphere entirely, return gradient background
|
||||||
|
if (discriminant < 0.0) {
|
||||||
|
let bg = gradient_background(uv, uniforms.aspectRatio);
|
||||||
|
return vec4<f32>(bg, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------
|
// -------------------------
|
||||||
// Raymarching Loop
|
// Raymarching Loop
|
||||||
// -------------------------
|
// -------------------------
|
||||||
var pos = campos;
|
// Start ray at intersection with bounding sphere for faster convergence
|
||||||
var tdist: f32 = 0.0; // Total distance traveled
|
let tstart = max(0.0, -b - sqrt(discriminant));
|
||||||
var dist: f32 = EPS; // Current step distance
|
var pos = campos + tstart * raydir;
|
||||||
|
var tdist: f32 = tstart;
|
||||||
|
var dist: f32 = EPS;
|
||||||
|
|
||||||
for (var i: i32 = 0; i < MAX_ITR; i = i + 1) {
|
for (var i: i32 = 0; i < MAX_ITR; i = i + 1) {
|
||||||
if (dist < EPS || tdist > MAX_DIS) {
|
if (dist < EPS || tdist > MAX_DIS) {
|
||||||
@@ -185,13 +228,15 @@ fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
|
|||||||
// Shading (on hit)
|
// Shading (on hit)
|
||||||
// -------------------------
|
// -------------------------
|
||||||
if (dist < EPS) {
|
if (dist < EPS) {
|
||||||
// Compute surface normal via central differences
|
// Compute surface normal via tetrahedral sampling (4 samples instead of 6)
|
||||||
let eps = vec2<f32>(0.0, EPS);
|
// More efficient than central differences while maintaining quality
|
||||||
let normal = normalize(vec3<f32>(
|
let e = vec2<f32>(1.0, -1.0) * 0.5773 * EPS;
|
||||||
map(pos + eps.yxx) - map(pos - eps.yxx),
|
let normal = normalize(
|
||||||
map(pos + eps.xyx) - map(pos - eps.xyx),
|
e.xyy * map(pos + e.xyy) +
|
||||||
map(pos + eps.xxy) - map(pos - eps.xxy)
|
e.yyx * map(pos + e.yyx) +
|
||||||
));
|
e.yxy * map(pos + e.yxy) +
|
||||||
|
e.xxx * map(pos + e.xxx)
|
||||||
|
);
|
||||||
|
|
||||||
// Diffuse lighting (Lambertian)
|
// Diffuse lighting (Lambertian)
|
||||||
let diffuse = max(0.0, dot(LIGHT_DIR, normal));
|
let diffuse = max(0.0, dot(LIGHT_DIR, normal));
|
||||||
@@ -219,7 +264,8 @@ fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------
|
// -------------------------
|
||||||
// Background (ray miss)
|
// Background (ray miss after marching)
|
||||||
// -------------------------
|
// -------------------------
|
||||||
return vec4<f32>(0.0, 0.0, 0.0, 0.0);
|
let bg = gradient_background(uv, uniforms.aspectRatio);
|
||||||
|
return vec4<f32>(bg, 1.0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
package io.visus.orbis.feature.orb.di
|
||||||
|
|
||||||
|
import io.visus.orbis.feature.orb.service.OrbRenderService
|
||||||
|
import io.visus.orbis.feature.orb.service.OrbRenderServiceImpl
|
||||||
|
import io.visus.orbis.feature.orb.ui.OrbViewModel
|
||||||
|
import org.koin.core.module.dsl.bind
|
||||||
|
import org.koin.core.module.dsl.singleOf
|
||||||
|
import org.koin.core.module.dsl.viewModelOf
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
val orbModule = module {
|
val orbModule = module {
|
||||||
|
singleOf(::OrbRenderServiceImpl) { bind<OrbRenderService>() }
|
||||||
|
viewModelOf(::OrbViewModel)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
material = "1.13.0"
|
||||||
lumoVersion = "1.2.5"
|
lumoVersion = "1.2.5"
|
||||||
webgpu = "1.0.0-alpha03"
|
webgpu = "1.0.0-alpha03"
|
||||||
|
nomanrComposables = "1.1.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
@@ -33,6 +34,7 @@ androidx-compose-material = { group = "androidx.compose.material", name = "mater
|
|||||||
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
|
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
|
||||||
androidx-compose-animation = { group = "androidx.compose.animation", name = "animation" }
|
androidx-compose-animation = { group = "androidx.compose.animation", name = "animation" }
|
||||||
androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" }
|
androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" }
|
||||||
|
androidx-compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||||
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||||
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
||||||
koin-bom = { group = "io.insert-koin", name = "koin-bom", version.ref = "koin" }
|
koin-bom = { group = "io.insert-koin", name = "koin-bom", version.ref = "koin" }
|
||||||
@@ -41,6 +43,7 @@ koin-android = { group = "io.insert-koin", name = "koin-android" }
|
|||||||
koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose" }
|
koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose" }
|
||||||
koin-androidx-compose-navigation = { group = "io.insert-koin", name = "koin-androidx-compose-navigation" }
|
koin-androidx-compose-navigation = { group = "io.insert-koin", name = "koin-androidx-compose-navigation" }
|
||||||
androidx-webgpu = { group = "androidx.webgpu", name = "webgpu", version.ref = "webgpu" }
|
androidx-webgpu = { group = "androidx.webgpu", name = "webgpu", version.ref = "webgpu" }
|
||||||
|
nomanr-composables = { group = "com.nomanr", name = "composables", version.ref = "nomanrComposables" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
@@ -22,6 +22,4 @@ dependencyResolutionManagement {
|
|||||||
rootProject.name = "Orbis"
|
rootProject.name = "Orbis"
|
||||||
include(":app")
|
include(":app")
|
||||||
include(":core:ui")
|
include(":core:ui")
|
||||||
include(":core:domain")
|
|
||||||
include(":core:data")
|
|
||||||
include(":feature:orb")
|
include(":feature:orb")
|
||||||
|
|||||||
Reference in New Issue
Block a user