From 7a95f7735cd82149b8786bbde002ae0355a8cd94 Mon Sep 17 00:00:00 2001 From: Bruno Deanoz Date: Thu, 18 Jun 2026 16:52:41 +0200 Subject: [PATCH] feat(ui): implement masterclass KaizenOrb with native 3D gyroscope parallax refraction and double conic caustics --- .../java/dev/kaizen/app/chat/Background.kt | 252 +++++++++++++++--- 1 file changed, 215 insertions(+), 37 deletions(-) diff --git a/app/src/main/java/dev/kaizen/app/chat/Background.kt b/app/src/main/java/dev/kaizen/app/chat/Background.kt index 4357031..7eee10f 100644 --- a/app/src/main/java/dev/kaizen/app/chat/Background.kt +++ b/app/src/main/java/dev/kaizen/app/chat/Background.kt @@ -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) + ) + } } }