feat(ui): implement masterclass KaizenOrb with native 3D gyroscope parallax refraction and double conic caustics
This commit is contained in:
parent
f8498adc27
commit
7a95f7735c
1 changed files with 215 additions and 37 deletions
|
|
@ -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
|
||||
)
|
||||
// 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))),
|
||||
)
|
||||
|
||||
// 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
|
||||
),
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue