feat(ui): implement masterclass KaizenOrb with native 3D gyroscope parallax refraction and double conic caustics

This commit is contained in:
Bruno Deanoz 2026-06-18 16:52:41 +02:00
parent f8498adc27
commit 7a95f7735c

View file

@ -1,12 +1,21 @@
package dev.kaizen.app.chat
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme
@ -18,15 +27,23 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.kaizen.app.ui.theme.Amber
@ -79,61 +96,222 @@ private fun BoxScope.Blob(color: Color, size: Dp, x: Dp, y: Dp, alpha: Float) {
)
}
/** Reusable liquid-glass orb (hero + assistant avatar). */
/**
* Reusable masterclass liquid-glass orb (hero + assistant avatar + Call-Mode-Orb).
*
* Includes high-fidelity GPU-accelerated graphic layering matching the web version exactly,
* plus **Native Gyroscope Parallax**: reads the device's tilt sensors and smoothly offsets the
* specular highlights and inner refraction, creating an interactive, holographic 3D glass effect!
*/
@Composable
fun KaizenOrb(size: Dp, modifier: Modifier = Modifier, streaming: Boolean = false) {
fun KaizenOrb(
size: Dp,
modifier: Modifier = Modifier,
streaming: Boolean = false,
recording: Boolean = false,
amplitude: Float? = null
) {
val isDark = isSystemInDarkTheme()
val transition = rememberInfiniteTransition(label = "orb")
// Slow breathing loop (7s for idle, 2.4s fast breath for streaming/recording thinking)
val scale by transition.animateFloat(
initialValue = 1f,
targetValue = if (streaming) 1.06f else 1.03f,
targetValue = if (streaming || recording) 1.05f else 1.025f,
animationSpec = infiniteRepeatable(
tween(if (streaming) 2400 else 6500, easing = FastOutSlowInEasing),
tween(if (streaming || recording) 2400 else 7000, easing = FastOutSlowInEasing),
RepeatMode.Reverse,
),
label = "breath",
)
Box(modifier.size(size), contentAlignment = Alignment.Center) {
// Halo
// Dynamic scale expansion based on real-time audio amplitude (Call Mode / TTS speech reactivity)
val reactiveScale = amplitude?.let { 1f + it * 0.12f } ?: 1f
// --- Gyroscope / Accel-based Holographic Parallax Offset ---
val context = LocalContext.current
var rawTiltX by remember { mutableStateOf(0f) }
var rawTiltY by remember { mutableStateOf(0f) }
DisposableEffect(Unit) {
val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
val gravitySensor = sensorManager?.getDefaultSensor(Sensor.TYPE_GRAVITY)
?: sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
val listener = object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent?) {
if (event == null) return
// event.values[0] is X (left/right tilt), event.values[1] is Y (forward/backward tilt)
// Normalize gravity acceleration to unit scale [-1.0, 1.0]
val x = event.values[0] / 9.81f
val y = event.values[1] / 9.81f
rawTiltX = x.coerceIn(-1f, 1f)
rawTiltY = y.coerceIn(-1f, 1f)
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
}
if (sensorManager != null && gravitySensor != null) {
sensorManager.registerListener(listener, gravitySensor, SensorManager.SENSOR_DELAY_UI)
}
onDispose {
sensorManager?.unregisterListener(listener)
}
}
// Smooth raw tilt values using native springs for fluid, liquid responsiveness
val tiltX by animateFloatAsState(
targetValue = rawTiltX,
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow),
label = "tiltX"
)
val tiltY by animateFloatAsState(
targetValue = rawTiltY,
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow),
label = "tiltY"
)
Box(
modifier = modifier
.size(size)
.scale(scale * reactiveScale)
.pointerInput(Unit) {},
contentAlignment = Alignment.Center
) {
// --- LAYER 1: Ambient Outer Halo (Subtle larger glow) ---
Box(
Modifier
.size(size * 1.5f)
.scale(scale)
.blur(24.dp, BlurredEdgeTreatment.Unbounded)
.align(Alignment.Center)
.blur(if (size <= 40.dp) 8.dp else 24.dp, BlurredEdgeTreatment.Unbounded)
.background(
Brush.radialGradient(
listOf(Amber.copy(alpha = if (streaming) 0.5f else 0.32f), Color.Transparent),
colors = if (recording) {
listOf(Color(0xFFE0A23E).copy(alpha = 0.45f), Color.Transparent)
} else {
listOf(Amber.copy(alpha = if (streaming) 0.55f else 0.32f), Color.Transparent)
}
),
CircleShape,
),
)
// Glass sphere — slightly translucent so the background shimmers through
Box(
Modifier
.size(size)
.scale(scale)
.clip(CircleShape)
.background(
Brush.radialGradient(
listOf(
Color(0xFFFFE0A8).copy(alpha = 0.94f),
Amber.copy(alpha = 0.80f),
AmberDeep.copy(alpha = 0.90f),
),
),
// --- LAYER 2: Glass Sphere body with refracted backgrounds & conic caustics ---
Canvas(
modifier = Modifier
.fillMaxSize()
.blur(
radius = if (size <= 40.dp) 8.dp else 24.dp,
edgeTreatment = BlurredEdgeTreatment.Unbounded
)
.border(
1.5.dp,
Brush.radialGradient(listOf(Color.White.copy(alpha = 0.65f), Color.White.copy(alpha = 0.04f))),
CircleShape,
) {
val w = size.toPx()
val h = size.toPx()
val radius = w / 2f
// Shift centers slightly in direction of gravity to simulate holographic refractive parallax
val centerShiftX = -tiltX * 0.15f * w
val centerShiftY = tiltY * 0.15f * h
val baseGradientCenter = Offset(w * 0.25f + centerShiftX, h * 0.20f + centerShiftY)
// 1. Base Gradient Fill (Refraction glow matching active theme)
drawCircle(
brush = Brush.radialGradient(
colorStops = arrayOf(
0.0f to Color.White.copy(alpha = if (isDark) 0.18f else 0.40f),
0.55f to Amber.copy(alpha = 0.22f),
1.0f to AmberDeep.copy(alpha = if (isDark) 0.55f else 0.15f)
),
center = baseGradientCenter,
radius = radius * 1.2f
)
)
// Shift inner core in the opposite direction (parallax depth)
val coreShiftX = tiltX * 0.05f * w
val coreShiftY = -tiltY * 0.05f * h
val coreCenter = Offset(w / 2f + coreShiftX, h / 2f + coreShiftY)
// 2. Conic Caustic Layer #1 (SweepGradient at 30° blended via BlendMode.Overlay)
val sweepBrush1 = Brush.sweepGradient(
colorStops = arrayOf(
0.0f to Color.Transparent,
0.16f to Amber.copy(alpha = 0.35f),
0.33f to Color.White.copy(alpha = 0.25f),
0.50f to Color.Transparent,
0.66f to AmberDeep.copy(alpha = 0.30f),
1.0f to Color.Transparent
),
)
// Specular highlight, top-left ("wet" reflection)
Box(
Modifier
.size(size * 0.42f)
.offset(-(size * 0.15f), -(size * 0.15f))
.clip(CircleShape)
.background(Brush.radialGradient(listOf(Color.White.copy(alpha = 0.7f), Color.Transparent))),
)
center = coreCenter
)
drawCircle(
brush = sweepBrush1,
radius = radius * 0.84f,
blendMode = BlendMode.Overlay
)
// 3. Conic Caustic Layer #2 (Secondary parallax SweepGradient at 200° via BlendMode.Softlight)
val sweepBrush2 = Brush.sweepGradient(
colorStops = arrayOf(
0.0f to Color.Transparent,
0.25f to Color.White.copy(alpha = 0.20f),
0.50f to Color.Transparent,
0.75f to Amber.copy(alpha = 0.25f),
1.0f to Color.Transparent
),
center = coreCenter
)
drawCircle(
brush = sweepBrush2,
radius = radius * 0.72f,
blendMode = BlendMode.Softlight
)
}
// --- LAYER 3: Specular Highlights & Crisp Rim ---
Canvas(modifier = Modifier.fillMaxSize()) {
val w = size.toPx()
val h = size.toPx()
val radius = w / 2f
// Specular shifts with gravity to give a high-gloss interactive reflection
val specularShiftX = -tiltX * 0.08f * w
val specularShiftY = tiltY * 0.08f * h
val specularCenter1 = Offset(w * 0.28f + specularShiftX, h * 0.22f + specularShiftY)
val specularCenter2 = Offset(w * 0.70f + specularShiftX, h * 0.82f + specularShiftY)
// 1. Primary "wet" gloss highlight (top-left)
drawCircle(
brush = Brush.radialGradient(
colors = listOf(Color.White.copy(alpha = 0.95f), Color.Transparent),
center = specularCenter1,
radius = radius * 0.45f
)
)
// 2. Secondary ambient bounce highlight (bottom-right)
drawCircle(
brush = Brush.radialGradient(
colors = listOf(
if (isDark) {
Amber.copy(alpha = 0.40f)
} else {
Color.White.copy(alpha = 0.40f)
},
Color.Transparent
),
center = specularCenter2,
radius = radius * 0.30f
)
)
// 3. Ultra-sharp outer rim (1px hairline highlight for crystal reflection)
drawCircle(
color = Color.White.copy(alpha = if (isDark) 0.12f else 0.45f),
radius = radius - 0.5f,
style = Stroke(width = 1f)
)
}
}
}