From 3f6bd6c128abc97dcecb0af3b9ca5569cb5e20e9 Mon Sep 17 00:00:00 2001 From: Alan Brault Date: Wed, 21 Jan 2026 11:05:33 -0500 Subject: [PATCH] feat(orb): implement orb animation Signed-off-by: Alan Brault --- CLAUDE.md | 80 +++ app/build.gradle.kts | 2 - .../main/java/io/visus/orbis/MainActivity.kt | 8 +- .../main/java/io/visus/orbis/di/AppModule.kt | 3 - .../java/io/visus/orbis/ui/main/MainScreen.kt | 42 -- .../io/visus/orbis/ui/main/MainViewModel.kt | 21 - core/data/.gitignore | 1 - core/data/build.gradle.kts | 48 -- core/data/consumer-rules.pro | 0 core/data/proguard-rules.pro | 21 - .../orbis/data/ExampleInstrumentedTest.kt | 24 - core/data/src/main/AndroidManifest.xml | 4 - .../io/visus/orbis/data/ExampleUnitTest.kt | 17 - core/domain/.gitignore | 1 - core/domain/build.gradle.kts | 45 -- core/domain/consumer-rules.pro | 0 core/domain/proguard-rules.pro | 21 - .../orbis/domain/ExampleInstrumentedTest.kt | 24 - core/domain/src/main/AndroidManifest.xml | 4 - .../io/visus/orbis/domain/ExampleUnitTest.kt | 17 - core/ui/build.gradle.kts | 4 + .../main/java/io/visus/orbis/core/ui/Color.kt | 11 + .../orbis/core/ui/components/IconButton.kt | 652 ++++++++++++++++++ .../core/ui/components/ModalBottomSheet.kt | 92 +++ .../orbis/core/ui/components/NavigationBar.kt | 318 --------- .../visus/orbis/core/ui/components/Slider.kt | 438 ++++++++++++ .../core/ui/foundation/ButtonElevation.kt | 133 ++++ feature/orb/build.gradle.kts | 2 +- feature/orb/src/main/assets/shaders/Orb.wgsl | 112 ++- .../visus/orbis/feature/orb/data/OrbState.kt | 29 + .../visus/orbis/feature/orb/di/OrbModule.kt | 8 + .../feature/orb/service/OrbRenderService.kt | 110 +++ .../orb/service/OrbRenderServiceImpl.kt | 611 ++++++++++++++++ .../visus/orbis/feature/orb/ui/OrbScreen.kt | 269 ++++++++ .../visus/orbis/feature/orb/ui/OrbSurface.kt | 56 ++ .../orbis/feature/orb/ui/OrbViewModel.kt | 159 +++++ .../feature/orb/util/CubemapGenerator.kt | 190 +++++ gradle/libs.versions.toml | 3 + settings.gradle.kts | 2 - 39 files changed, 2929 insertions(+), 653 deletions(-) create mode 100644 CLAUDE.md delete mode 100644 app/src/main/java/io/visus/orbis/ui/main/MainScreen.kt delete mode 100644 app/src/main/java/io/visus/orbis/ui/main/MainViewModel.kt delete mode 100644 core/data/.gitignore delete mode 100644 core/data/build.gradle.kts delete mode 100644 core/data/consumer-rules.pro delete mode 100644 core/data/proguard-rules.pro delete mode 100644 core/data/src/androidTest/java/io/visus/orbis/data/ExampleInstrumentedTest.kt delete mode 100644 core/data/src/main/AndroidManifest.xml delete mode 100644 core/data/src/test/java/io/visus/orbis/data/ExampleUnitTest.kt delete mode 100644 core/domain/.gitignore delete mode 100644 core/domain/build.gradle.kts delete mode 100644 core/domain/consumer-rules.pro delete mode 100644 core/domain/proguard-rules.pro delete mode 100644 core/domain/src/androidTest/java/io/visus/orbis/domain/ExampleInstrumentedTest.kt delete mode 100644 core/domain/src/main/AndroidManifest.xml delete mode 100644 core/domain/src/test/java/io/visus/orbis/domain/ExampleUnitTest.kt create mode 100644 core/ui/src/main/java/io/visus/orbis/core/ui/components/IconButton.kt create mode 100644 core/ui/src/main/java/io/visus/orbis/core/ui/components/ModalBottomSheet.kt delete mode 100644 core/ui/src/main/java/io/visus/orbis/core/ui/components/NavigationBar.kt create mode 100644 core/ui/src/main/java/io/visus/orbis/core/ui/components/Slider.kt create mode 100644 core/ui/src/main/java/io/visus/orbis/core/ui/foundation/ButtonElevation.kt create mode 100644 feature/orb/src/main/java/io/visus/orbis/feature/orb/data/OrbState.kt create mode 100644 feature/orb/src/main/java/io/visus/orbis/feature/orb/service/OrbRenderService.kt create mode 100644 feature/orb/src/main/java/io/visus/orbis/feature/orb/service/OrbRenderServiceImpl.kt create mode 100644 feature/orb/src/main/java/io/visus/orbis/feature/orb/ui/OrbScreen.kt create mode 100644 feature/orb/src/main/java/io/visus/orbis/feature/orb/ui/OrbSurface.kt create mode 100644 feature/orb/src/main/java/io/visus/orbis/feature/orb/ui/OrbViewModel.kt create mode 100644 feature/orb/src/main/java/io/visus/orbis/feature/orb/util/CubemapGenerator.kt diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0d019c4 --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bc759fa..e3b5142 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -40,8 +40,6 @@ android { dependencies { // Project modules implementation(project(":core:ui")) - implementation(project(":core:domain")) - implementation(project(":core:data")) implementation(project(":feature:orb")) // AndroidX diff --git a/app/src/main/java/io/visus/orbis/MainActivity.kt b/app/src/main/java/io/visus/orbis/MainActivity.kt index 2a4fe0c..9961c5e 100644 --- a/app/src/main/java/io/visus/orbis/MainActivity.kt +++ b/app/src/main/java/io/visus/orbis/MainActivity.kt @@ -5,12 +5,12 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import io.visus.orbis.core.ui.OrbisTheme -import io.visus.orbis.ui.main.MainScreen -import io.visus.orbis.ui.main.MainViewModel +import io.visus.orbis.feature.orb.ui.OrbScreen +import io.visus.orbis.feature.orb.ui.OrbViewModel import org.koin.androidx.viewmodel.ext.android.viewModel class MainActivity : ComponentActivity() { - private val viewModel: MainViewModel by viewModel() + private val orbViewModel: OrbViewModel by viewModel() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -18,7 +18,7 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() setContent { OrbisTheme { - MainScreen(viewModel = viewModel) + OrbScreen(viewModel = orbViewModel) } } } diff --git a/app/src/main/java/io/visus/orbis/di/AppModule.kt b/app/src/main/java/io/visus/orbis/di/AppModule.kt index 576707b..b4cdaef 100644 --- a/app/src/main/java/io/visus/orbis/di/AppModule.kt +++ b/app/src/main/java/io/visus/orbis/di/AppModule.kt @@ -1,9 +1,6 @@ package io.visus.orbis.di -import io.visus.orbis.ui.main.MainViewModel -import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module val appModule = module { - viewModelOf(::MainViewModel) } diff --git a/app/src/main/java/io/visus/orbis/ui/main/MainScreen.kt b/app/src/main/java/io/visus/orbis/ui/main/MainScreen.kt deleted file mode 100644 index c3a412c..0000000 --- a/app/src/main/java/io/visus/orbis/ui/main/MainScreen.kt +++ /dev/null @@ -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()) - } -} diff --git a/app/src/main/java/io/visus/orbis/ui/main/MainViewModel.kt b/app/src/main/java/io/visus/orbis/ui/main/MainViewModel.kt deleted file mode 100644 index 2f2d734..0000000 --- a/app/src/main/java/io/visus/orbis/ui/main/MainViewModel.kt +++ /dev/null @@ -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 = _uiState.asStateFlow() - - fun updateGreeting(name: String) { - _uiState.update { it.copy(greeting = name) } - } -} diff --git a/core/data/.gitignore b/core/data/.gitignore deleted file mode 100644 index 42afabf..0000000 --- a/core/data/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts deleted file mode 100644 index 4a83a72..0000000 --- a/core/data/build.gradle.kts +++ /dev/null @@ -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) -} \ No newline at end of file diff --git a/core/data/consumer-rules.pro b/core/data/consumer-rules.pro deleted file mode 100644 index e69de29..0000000 diff --git a/core/data/proguard-rules.pro b/core/data/proguard-rules.pro deleted file mode 100644 index 481bb43..0000000 --- a/core/data/proguard-rules.pro +++ /dev/null @@ -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 \ No newline at end of file diff --git a/core/data/src/androidTest/java/io/visus/orbis/data/ExampleInstrumentedTest.kt b/core/data/src/androidTest/java/io/visus/orbis/data/ExampleInstrumentedTest.kt deleted file mode 100644 index 56e8138..0000000 --- a/core/data/src/androidTest/java/io/visus/orbis/data/ExampleInstrumentedTest.kt +++ /dev/null @@ -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) - } -} \ No newline at end of file diff --git a/core/data/src/main/AndroidManifest.xml b/core/data/src/main/AndroidManifest.xml deleted file mode 100644 index a5918e6..0000000 --- a/core/data/src/main/AndroidManifest.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/core/data/src/test/java/io/visus/orbis/data/ExampleUnitTest.kt b/core/data/src/test/java/io/visus/orbis/data/ExampleUnitTest.kt deleted file mode 100644 index 19744f5..0000000 --- a/core/data/src/test/java/io/visus/orbis/data/ExampleUnitTest.kt +++ /dev/null @@ -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) - } -} \ No newline at end of file diff --git a/core/domain/.gitignore b/core/domain/.gitignore deleted file mode 100644 index 42afabf..0000000 --- a/core/domain/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts deleted file mode 100644 index d7170db..0000000 --- a/core/domain/build.gradle.kts +++ /dev/null @@ -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) -} \ No newline at end of file diff --git a/core/domain/consumer-rules.pro b/core/domain/consumer-rules.pro deleted file mode 100644 index e69de29..0000000 diff --git a/core/domain/proguard-rules.pro b/core/domain/proguard-rules.pro deleted file mode 100644 index 481bb43..0000000 --- a/core/domain/proguard-rules.pro +++ /dev/null @@ -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 \ No newline at end of file diff --git a/core/domain/src/androidTest/java/io/visus/orbis/domain/ExampleInstrumentedTest.kt b/core/domain/src/androidTest/java/io/visus/orbis/domain/ExampleInstrumentedTest.kt deleted file mode 100644 index b2a1a2b..0000000 --- a/core/domain/src/androidTest/java/io/visus/orbis/domain/ExampleInstrumentedTest.kt +++ /dev/null @@ -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) - } -} \ No newline at end of file diff --git a/core/domain/src/main/AndroidManifest.xml b/core/domain/src/main/AndroidManifest.xml deleted file mode 100644 index a5918e6..0000000 --- a/core/domain/src/main/AndroidManifest.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/core/domain/src/test/java/io/visus/orbis/domain/ExampleUnitTest.kt b/core/domain/src/test/java/io/visus/orbis/domain/ExampleUnitTest.kt deleted file mode 100644 index af26fec..0000000 --- a/core/domain/src/test/java/io/visus/orbis/domain/ExampleUnitTest.kt +++ /dev/null @@ -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) - } -} \ No newline at end of file diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index c422044..153d0ef 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -45,11 +45,15 @@ dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.foundation) implementation(libs.androidx.compose.animation) implementation(libs.androidx.compose.material) implementation(libs.androidx.compose.runtime) + // Composables (bottom sheet, slider) + api(libs.nomanr.composables) + // Testing testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) diff --git a/core/ui/src/main/java/io/visus/orbis/core/ui/Color.kt b/core/ui/src/main/java/io/visus/orbis/core/ui/Color.kt index 49e4fc2..21429f0 100644 --- a/core/ui/src/main/java/io/visus/orbis/core/ui/Color.kt +++ b/core/ui/src/main/java/io/visus/orbis/core/ui/Color.kt @@ -3,6 +3,7 @@ package io.visus.orbis.core.ui import androidx.compose.runtime.Immutable import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color val Black: Color = Color(0xFF000000) @@ -52,6 +53,9 @@ val Green200: Color = Color(0xFFC2F5DA) val Green100: Color = Color(0xFFD0FBE9) val Green50: Color = Color(0xFFE0FAEC) +val NavyBlue900: Color = Color(0xFF00002E) +val NavyBlue800: Color = Color(0xFF1A1A47) + @Immutable data class Colors( val primary: Color, @@ -79,6 +83,7 @@ data class Colors( val textDisabled: Color, val scrim: Color, val elevation: Color, + val defaultBackgroundGradient: Brush, ) internal val LightColors = @@ -108,6 +113,9 @@ internal val LightColors = textDisabled = Gray400, scrim = Color.Black.copy(alpha = 0.32f), elevation = Gray700, + defaultBackgroundGradient = Brush.verticalGradient( + colors = listOf(NavyBlue800, NavyBlue900, Black) + ), ) internal val DarkColors = @@ -137,6 +145,9 @@ internal val DarkColors = textDisabled = Gray600, scrim = Color.Black.copy(alpha = 0.72f), elevation = Gray200, + defaultBackgroundGradient = Brush.verticalGradient( + colors = listOf(NavyBlue800, NavyBlue900, Black) + ), ) val LocalColors = staticCompositionLocalOf { LightColors } diff --git a/core/ui/src/main/java/io/visus/orbis/core/ui/components/IconButton.kt b/core/ui/src/main/java/io/visus/orbis/core/ui/components/IconButton.kt new file mode 100644 index 0000000..c0e4a81 --- /dev/null +++ b/core/ui/src/main/java/io/visus/orbis/core/ui/components/IconButton.kt @@ -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, + ) + } +} diff --git a/core/ui/src/main/java/io/visus/orbis/core/ui/components/ModalBottomSheet.kt b/core/ui/src/main/java/io/visus/orbis/core/ui/components/ModalBottomSheet.kt new file mode 100644 index 0000000..b45a06f --- /dev/null +++ b/core/ui/src/main/java/io/visus/orbis/core/ui/components/ModalBottomSheet.kt @@ -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), + ) + } + } + } +} diff --git a/core/ui/src/main/java/io/visus/orbis/core/ui/components/NavigationBar.kt b/core/ui/src/main/java/io/visus/orbis/core/ui/components/NavigationBar.kt deleted file mode 100644 index d660ec8..0000000 --- a/core/ui/src/main/java/io/visus/orbis/core/ui/components/NavigationBar.kt +++ /dev/null @@ -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 { - val targetValue = - when { - !enabled -> disabledIconColor - selected -> selectedIconColor - else -> unselectedIconColor - } - return animateColorAsState( - targetValue = targetValue, - animationSpec = tween(NavigationBarItemDefaults.ItemAnimationDurationMillis), - label = "icon-color", - ) - } - - @Composable - internal fun textColor(selected: Boolean, enabled: Boolean): State { - val targetValue = - when { - !enabled -> disabledTextColor - selected -> selectedTextColor - else -> unselectedTextColor - } - return animateColorAsState( - targetValue = targetValue, - animationSpec = tween(NavigationBarItemDefaults.ItemAnimationDurationMillis), - label = "text-color", - ) - } -} - -private const val IconLayoutIdTag: String = "icon" -private const val LabelLayoutIdTag: String = "label" diff --git a/core/ui/src/main/java/io/visus/orbis/core/ui/components/Slider.kt b/core/ui/src/main/java/io/visus/orbis/core/ui/components/Slider.kt new file mode 100644 index 0000000..f5eab40 --- /dev/null +++ b/core/ui/src/main/java/io/visus/orbis/core/ui/components/Slider.kt @@ -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 = 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, + onValueChange: (ClosedFloatingPointRange) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + valueRange: ClosedFloatingPointRange = 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(), + ) + } + } + } +} diff --git a/core/ui/src/main/java/io/visus/orbis/core/ui/foundation/ButtonElevation.kt b/core/ui/src/main/java/io/visus/orbis/core/ui/foundation/ButtonElevation.kt new file mode 100644 index 0000000..8085815 --- /dev/null +++ b/core/ui/src/main/java/io/visus/orbis/core/ui/foundation/ButtonElevation.kt @@ -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 { + return animateElevation(enabled = enabled, interactionSource = interactionSource) + } + + @Composable + private fun animateElevation( + enabled: Boolean, + interactionSource: InteractionSource, + ): State { + val interactions = remember { mutableStateListOf() } + 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 + } +} diff --git a/feature/orb/build.gradle.kts b/feature/orb/build.gradle.kts index 9c1be64..394c6b4 100644 --- a/feature/orb/build.gradle.kts +++ b/feature/orb/build.gradle.kts @@ -37,7 +37,6 @@ android { dependencies { // Project modules implementation(project(":core:ui")) - implementation(project(":core:domain")) // AndroidX implementation(libs.androidx.core.ktx) @@ -49,6 +48,7 @@ dependencies { implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons) implementation(libs.androidx.compose.foundation) implementation(libs.androidx.compose.runtime) diff --git a/feature/orb/src/main/assets/shaders/Orb.wgsl b/feature/orb/src/main/assets/shaders/Orb.wgsl index b112db9..b347f79 100644 --- a/feature/orb/src/main/assets/shaders/Orb.wgsl +++ b/feature/orb/src/main/assets/shaders/Orb.wgsl @@ -22,11 +22,21 @@ // Constants // ---------------------------------------------------------------------------- +// Background radial gradient colors (light gray center to darker gray edge) +// #d0d0d0 at center (0%) +const GRADIENT_CENTER: vec3 = vec3(0.816, 0.816, 0.816); +// #a8a8a8 at 70% +const GRADIENT_MID: vec3 = vec3(0.659, 0.659, 0.659); +// #909090 at edge (100%) +const GRADIENT_EDGE: vec3 = vec3(0.565, 0.565, 0.565); + // Surface intersection threshold - rays closer than this are considered hits -const EPS: f32 = 0.01; +// Larger value = faster convergence but less precision (mobile optimization) +const EPS: f32 = 0.04; // Maximum raymarching iterations to prevent infinite loops -const MAX_ITR: i32 = 100; +// Reduced from 100 for mobile GPU performance +const MAX_ITR: i32 = 32; // Maximum ray travel distance before giving up (ray miss) const MAX_DIS: f32 = 10.0; @@ -104,31 +114,47 @@ fn sd_sph(p: vec3, r: f32) -> f32 { // Evaluates the distance field at point `p`. // Combines a base sphere with animated noise displacement. fn map(p: vec3) -> f32 { - // Base UV from world position (scaled down for tiling) - let u = p.xy * 0.2; + // Animated UV for displacement (single sample for mobile performance) + var um = p.xy * 0.06; + um.x = um.x + uniforms.time * 0.1; + um.y = um.y - uniforms.time * 0.025; + um.x = um.x + um.y * 2.0; - // Animated UV for large-scale displacement - // Creates slow, flowing motion across the surface - var um = u * 0.3; - um.x = um.x + uniforms.time * 0.1; // Horizontal drift - um.y = um.y - uniforms.time * 0.025; // Slower vertical drift - um.x = um.x + um.y * 2.0; // Shear for diagonal motion + // Single noise sample (mobile optimization - was 2 samples) + let noise = textureSampleLevel(noiseTexture, texSampler, um, 0.0).x; - // Sample noise at two frequencies: - // - hlg: Large-scale, animated features (the "goo" blobs) - // - hfn: Fine detail, static relative to surface - let hlg = textureSampleLevel(noiseTexture, texSampler, um, 0.0).x; - let hfn = textureSampleLevel(noiseTexture, texSampler, u, 0.0).x; - - // Combine noise samples with amplitude control - // Fine detail is reduced where large features are prominent + // Apply displacement with amplitude control let amp = max(uniforms.amplitude, 0.15); - let disp = (hlg * 0.4 + hfn * 0.1 * (1.0 - hlg)) * amp; + let disp = noise * 0.5 * amp; - // Return displaced sphere distance return sd_sph(p, 1.5) + disp; } +// ---------------------------------------------------------------------------- +// Background Gradient +// ---------------------------------------------------------------------------- + +// Computes the radial gradient background color based on distance from center. +// Gradient: #d0d0d0 (center) -> #a8a8a8 (70%) -> #909090 (edge) +fn gradient_background(uv: vec2, aspect: f32) -> vec3 { + // Center the UV coordinates + var centered = uv - vec2(0.5, 0.5); + // Apply aspect ratio correction so gradient is circular not elliptical + centered.x = centered.x * aspect; + // Distance from center (0 at center, ~0.5-0.7 at edges depending on aspect) + let dist = length(centered) * 2.0; // Scale so edges are roughly 1.0 + + if (dist < 0.7) { + // Center to 70%: blend from CENTER to MID + let t = dist / 0.7; + return mix(GRADIENT_CENTER, GRADIENT_MID, t); + } else { + // 70% to edge: blend from MID to EDGE + let t = min((dist - 0.7) / 0.3, 1.0); + return mix(GRADIENT_MID, GRADIENT_EDGE, t); + } +} + // ---------------------------------------------------------------------------- // Lighting Utilities // ---------------------------------------------------------------------------- @@ -162,15 +188,32 @@ fn fs_main(input: VertexOutput) -> @location(0) vec4 { // ------------------------- // Camera Setup // ------------------------- - let campos = vec3(0.0, 0.0, -2.9); // Camera position (in front of sphere) + let campos = vec3(0.0, 0.0, -5.5); // Camera position (moved back for smaller orb) let raydir = normalize(vec3(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(bg, 1.0); + } + // ------------------------- // Raymarching Loop // ------------------------- - var pos = campos; - var tdist: f32 = 0.0; // Total distance traveled - var dist: f32 = EPS; // Current step distance + // Start ray at intersection with bounding sphere for faster convergence + let tstart = max(0.0, -b - sqrt(discriminant)); + var pos = campos + tstart * raydir; + var tdist: f32 = tstart; + var dist: f32 = EPS; for (var i: i32 = 0; i < MAX_ITR; i = i + 1) { if (dist < EPS || tdist > MAX_DIS) { @@ -185,13 +228,15 @@ fn fs_main(input: VertexOutput) -> @location(0) vec4 { // Shading (on hit) // ------------------------- if (dist < EPS) { - // Compute surface normal via central differences - let eps = vec2(0.0, EPS); - let normal = normalize(vec3( - map(pos + eps.yxx) - map(pos - eps.yxx), - map(pos + eps.xyx) - map(pos - eps.xyx), - map(pos + eps.xxy) - map(pos - eps.xxy) - )); + // Compute surface normal via tetrahedral sampling (4 samples instead of 6) + // More efficient than central differences while maintaining quality + let e = vec2(1.0, -1.0) * 0.5773 * EPS; + let normal = normalize( + e.xyy * map(pos + e.xyy) + + e.yyx * map(pos + e.yyx) + + e.yxy * map(pos + e.yxy) + + e.xxx * map(pos + e.xxx) + ); // Diffuse lighting (Lambertian) let diffuse = max(0.0, dot(LIGHT_DIR, normal)); @@ -219,7 +264,8 @@ fn fs_main(input: VertexOutput) -> @location(0) vec4 { } // ------------------------- - // Background (ray miss) + // Background (ray miss after marching) // ------------------------- - return vec4(0.0, 0.0, 0.0, 0.0); + let bg = gradient_background(uv, uniforms.aspectRatio); + return vec4(bg, 1.0); } diff --git a/feature/orb/src/main/java/io/visus/orbis/feature/orb/data/OrbState.kt b/feature/orb/src/main/java/io/visus/orbis/feature/orb/data/OrbState.kt new file mode 100644 index 0000000..fc239ca --- /dev/null +++ b/feature/orb/src/main/java/io/visus/orbis/feature/orb/data/OrbState.kt @@ -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 +) diff --git a/feature/orb/src/main/java/io/visus/orbis/feature/orb/di/OrbModule.kt b/feature/orb/src/main/java/io/visus/orbis/feature/orb/di/OrbModule.kt index 6c1de38..e05931e 100644 --- a/feature/orb/src/main/java/io/visus/orbis/feature/orb/di/OrbModule.kt +++ b/feature/orb/src/main/java/io/visus/orbis/feature/orb/di/OrbModule.kt @@ -1,6 +1,14 @@ package io.visus.orbis.feature.orb.di +import io.visus.orbis.feature.orb.service.OrbRenderService +import io.visus.orbis.feature.orb.service.OrbRenderServiceImpl +import io.visus.orbis.feature.orb.ui.OrbViewModel +import org.koin.core.module.dsl.bind +import org.koin.core.module.dsl.singleOf +import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module val orbModule = module { + singleOf(::OrbRenderServiceImpl) { bind() } + viewModelOf(::OrbViewModel) } diff --git a/feature/orb/src/main/java/io/visus/orbis/feature/orb/service/OrbRenderService.kt b/feature/orb/src/main/java/io/visus/orbis/feature/orb/service/OrbRenderService.kt new file mode 100644 index 0000000..4e3413e --- /dev/null +++ b/feature/orb/src/main/java/io/visus/orbis/feature/orb/service/OrbRenderService.kt @@ -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 + + /** + * Emits error messages when initialization or rendering fails, `null` otherwise. + */ + val error: StateFlow + + /** + * Emits the current frames per second, updated approximately once per second. + */ + val fps: StateFlow + + /** + * 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() +} diff --git a/feature/orb/src/main/java/io/visus/orbis/feature/orb/service/OrbRenderServiceImpl.kt b/feature/orb/src/main/java/io/visus/orbis/feature/orb/service/OrbRenderServiceImpl.kt new file mode 100644 index 0000000..a846552 --- /dev/null +++ b/feature/orb/src/main/java/io/visus/orbis/feature/orb/service/OrbRenderServiceImpl.kt @@ -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 = _isInitialized.asStateFlow() + + private val _error = MutableStateFlow(null) + override val error: StateFlow = _error.asStateFlow() + + private val _fps = MutableStateFlow(0) + override val fps: StateFlow = _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) - 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) - 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) + } +} diff --git a/feature/orb/src/main/java/io/visus/orbis/feature/orb/ui/OrbScreen.kt b/feature/orb/src/main/java/io/visus/orbis/feature/orb/ui/OrbScreen.kt new file mode 100644 index 0000000..565463e --- /dev/null +++ b/feature/orb/src/main/java/io/visus/orbis/feature/orb/ui/OrbScreen.kt @@ -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) + ) + } +} diff --git a/feature/orb/src/main/java/io/visus/orbis/feature/orb/ui/OrbSurface.kt b/feature/orb/src/main/java/io/visus/orbis/feature/orb/ui/OrbSurface.kt new file mode 100644 index 0000000..730a08c --- /dev/null +++ b/feature/orb/src/main/java/io/visus/orbis/feature/orb/ui/OrbSurface.kt @@ -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 + ) +} diff --git a/feature/orb/src/main/java/io/visus/orbis/feature/orb/ui/OrbViewModel.kt b/feature/orb/src/main/java/io/visus/orbis/feature/orb/ui/OrbViewModel.kt new file mode 100644 index 0000000..9178e6f --- /dev/null +++ b/feature/orb/src/main/java/io/visus/orbis/feature/orb/ui/OrbViewModel.kt @@ -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 = _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() + } +} diff --git a/feature/orb/src/main/java/io/visus/orbis/feature/orb/util/CubemapGenerator.kt b/feature/orb/src/main/java/io/visus/orbis/feature/orb/util/CubemapGenerator.kt new file mode 100644 index 0000000..d979f5b --- /dev/null +++ b/feature/orb/src/main/java/io/visus/orbis/feature/orb/util/CubemapGenerator.kt @@ -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 +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7e9dff7..181a9ff 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ appcompat = "1.7.1" material = "1.13.0" lumoVersion = "1.2.5" webgpu = "1.0.0-alpha03" +nomanrComposables = "1.1.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -33,6 +34,7 @@ androidx-compose-material = { group = "androidx.compose.material", name = "mater androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } androidx-compose-animation = { group = "androidx.compose.animation", name = "animation" } androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" } +androidx-compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } koin-bom = { group = "io.insert-koin", name = "koin-bom", version.ref = "koin" } @@ -41,6 +43,7 @@ koin-android = { group = "io.insert-koin", name = "koin-android" } koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose" } koin-androidx-compose-navigation = { group = "io.insert-koin", name = "koin-androidx-compose-navigation" } androidx-webgpu = { group = "androidx.webgpu", name = "webgpu", version.ref = "webgpu" } +nomanr-composables = { group = "com.nomanr", name = "composables", version.ref = "nomanrComposables" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 2fa3b77..e029b8b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -22,6 +22,4 @@ dependencyResolutionManagement { rootProject.name = "Orbis" include(":app") include(":core:ui") -include(":core:domain") -include(":core:data") include(":feature:orb")