package com.rizoma.orbita.ui 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.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind 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.graphics.graphicsLayer import androidx.compose.ui.graphics.lerp import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.rizoma.orbita.model.AppNode import com.rizoma.orbita.ui.theme.NeonCian import com.rizoma.orbita.ui.theme.NeonRosa import com.rizoma.orbita.ui.theme.NeonVerde import com.rizoma.orbita.ui.theme.NucleoPaleta import com.rizoma.orbita.ui.theme.Tenue import kotlin.math.PI import kotlin.math.cos import kotlin.math.floor import kotlin.math.max import kotlin.math.min import kotlin.math.sin /** La casilla orbital de un nodo: en qué anillo cae y con qué ángulo de partida. */ private data class Slot( val ring: Int, val angle: Float, val radius: Float, ) /** El reparto entero: una casilla por clave de nodo, más cuántos anillos ocupa. */ private data class Orbitas(val slots: Map, val rings: Int) private val ringColors = listOf(NeonVerde, NeonRosa, NeonCian) private const val TAU_F = (2 * PI).toFloat() /** Una vuelta completa del tinte del núcleo: tan larga que el cambio no se percibe. */ private const val TINTE_MS = 900_000 /** * Distribución anti-rejilla: los nodos caen en órbitas concéntricas según su calor. * Las órbitas internas (más cerca del centro) son las apps más usadas y las fijadas. * * Dos reglas mantienen la ronda estable (nada de saltos): * - el reparto en casillas se congela: el orden por calor decide la primera vez, * y a partir de ahí un nodo conserva su casilla aunque su calor cambie al usarlo; * - la posición se calcula en la fase de dibujo (graphicsLayer), no recomponiendo: * la vuelta va a 60 fps sin depender de que Compose recomponga cada fotograma. */ @OptIn(ExperimentalFoundationApi::class) @Composable fun OrbitLayout( nodes: List, onLaunch: (AppNode) -> Unit, onLongPress: (AppNode) -> Unit, onCenterTap: () -> Unit, modifier: Modifier = Modifier, ) { val density = LocalDensity.current val transition = rememberInfiniteTransition(label = "orbita") // Sin `by`: el State se lee dentro de lambdas de dibujo, así el giro no recompone. val spin = transition.animateFloat( initialValue = 0f, targetValue = TAU_F, animationSpec = infiniteRepeatable(tween(180_000, easing = LinearEasing), RepeatMode.Restart), label = "spin", ) val pulse = transition.animateFloat( initialValue = 0.94f, targetValue = 1.06f, animationSpec = infiniteRepeatable(tween(5_200, easing = LinearEasing), RepeatMode.Reverse), label = "pulso", ) // Deriva de color del núcleo: recorre la paleta entera en 15 minutos. val tinte = transition.animateFloat( initialValue = 0f, targetValue = NucleoPaleta.size.toFloat(), animationSpec = infiniteRepeatable(tween(TINTE_MS, easing = LinearEasing), RepeatMode.Restart), label = "tinte", ) val glow: () -> Color = { colorDelCiclo(tinte.value) } BoxWithConstraints(modifier.fillMaxSize()) { val wPx = with(density) { maxWidth.toPx() } val hPx = with(density) { maxHeight.toPx() } val cx = wPx / 2f val cy = hPx / 2f val firstR = with(density) { 116.dp.toPx() } val ringGap = with(density) { 86.dp.toPx() } val spacing = with(density) { 76.dp.toPx() } val colHalf = with(density) { 32.dp.toPx() } val iconHalf = with(density) { 27.dp.toPx() } val orbitas = rememberOrbitas(nodes, firstR, ringGap, spacing) val rings = orbitas.rings // Halo del núcleo (mucho alcance, muy difuminado) + anillos orbitales. Canvas(Modifier.fillMaxSize()) { val c = glow() val p = pulse.value val halo = min(wPx, hPx) * 0.58f * p drawCircle( brush = Brush.radialGradient( 0.00f to c.copy(alpha = 0.22f), 0.16f to c.copy(alpha = 0.115f), 0.38f to c.copy(alpha = 0.055f), 0.66f to c.copy(alpha = 0.018f), 1.00f to Color.Transparent, center = Offset(cx, cy), radius = halo, ), radius = halo, center = Offset(cx, cy), blendMode = BlendMode.Plus, ) for (r in 0 until rings) { // Los anillos se tiñen a medias con el color vivo del núcleo. drawCircle( color = lerp(ringColors[r % ringColors.size], c, 0.5f).copy(alpha = 0.14f), radius = firstR + r * ringGap, center = Offset(cx, cy), style = Stroke(width = 1.2f), ) } } // Núcleo: toque para buscar en el rizoma. CenterHub( modifier = Modifier.align(Alignment.Center), glow = glow, pulse = pulse, onTap = onCenterTap, ) // Nodos en órbita. Si una casilla cambia (se instala o se expulsa una app), // el icono viaja hasta ella con un muelle lento: nunca reaparece en otro sitio. nodes.forEach { node -> val slot = orbitas.slots[node.key] ?: return@forEach key(node.key) { val angle = animateFloatAsState( targetValue = slot.angle, animationSpec = spring(dampingRatio = 1f, stiffness = Spring.StiffnessVeryLow), label = "ang", ) val radius = animateFloatAsState( targetValue = slot.radius, animationSpec = spring(dampingRatio = 1f, stiffness = Spring.StiffnessVeryLow), label = "rad", ) // Anillos contiguos contrarrotan. Factor entero (±1) → el bucle es continuo. val dir = if (slot.ring % 2 == 0) 1f else -1f NodeItem( node = node, modifier = Modifier .align(Alignment.TopStart) .graphicsLayer { val a = angle.value + spin.value * dir translationX = cx + radius.value * cos(a) - colHalf translationY = cy + radius.value * sin(a) - iconHalf }, onLaunch = onLaunch, onLongPress = onLongPress, ) } } } } /** * Casillas estables. El orden solo se recalcula cuando cambia la *firma* del átomo: * qué apps hay, cuáles están fijadas y cuáles alejadas. El calor sube al usar una app, * pero no mueve nada por su cuenta — el átomo se reordena cuando tú actúas sobre él, * nunca a tus espaldas mientras miras la ronda. Y aun entonces, cada icono viaja a su * nueva casilla con un muelle lento: nunca reaparece de golpe en otro sitio. */ @Composable private fun rememberOrbitas( nodes: List, firstR: Float, ringGap: Float, spacing: Float, ): Orbitas { val anguloPrevio = remember { HashMap() } val firma = nodes.joinToString("|") { "${it.key}#${if (it.pinned) 1 else 0}#${it.penalty}" } return remember(firma, firstR, ringGap, spacing) { val slots = HashMap(nodes.size) var rings = 0 computePlacements(nodes.map { it.key }, firstR, ringGap, spacing).forEach { (clave, slot) -> // El ángulo se "desenrolla" respecto al anterior para que la animación // tome el camino corto y no dé una vuelta entera de más. val previo = anguloPrevio[clave] val angulo = if (previo == null) slot.angle else desenrollar(previo, slot.angle) anguloPrevio[clave] = angulo slots[clave] = slot.copy(angle = angulo) rings = max(rings, slot.ring + 1) } anguloPrevio.keys.retainAll(slots.keys) Orbitas(slots, rings) } } private fun desenrollar(previo: Float, destino: Float): Float { var a = destino while (a - previo > TAU_F / 2f) a -= TAU_F while (previo - a > TAU_F / 2f) a += TAU_F return a } /** Color continuo a lo largo de la paleta del núcleo (t recorre 0..paleta.size). */ private fun colorDelCiclo(t: Float): Color { val n = NucleoPaleta.size val base = floor(t).toInt() val f = t - base return lerp(NucleoPaleta[((base % n) + n) % n], NucleoPaleta[((base + 1) % n + n) % n], f) } @OptIn(ExperimentalFoundationApi::class) @Composable private fun NodeItem( node: AppNode, modifier: Modifier, onLaunch: (AppNode) -> Unit, onLongPress: (AppNode) -> Unit, ) { Column( modifier = modifier.width(64.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { val iconMod = Modifier .size(54.dp) .clip(CircleShape) .combinedClickable( onClick = { onLaunch(node) }, onLongClick = { onLongPress(node) }, ) .then( if (node.pinned) Modifier.border(1.5.dp, NeonRosa, CircleShape) else Modifier ) Image( bitmap = node.icon, contentDescription = node.label, modifier = iconMod, ) Text( text = node.label, color = Tenue, fontSize = 10.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center, ) } } /** * El corazón del átomo: un núcleo blanco muy brillante envuelto en el color del ciclo. * El halo amplio lo pinta el lienzo de fondo; aquí solo va el punto vivo y su toque. */ @Composable private fun CenterHub( modifier: Modifier, glow: () -> Color, pulse: State, onTap: () -> Unit, ) { Column( modifier = modifier .size(96.dp) .clip(CircleShape) .drawBehind { val c = glow() val r = size.minDimension / 2f * pulse.value drawCircle( brush = Brush.radialGradient( 0.00f to Color.White.copy(alpha = 0.42f), 0.10f to c.copy(alpha = 0.80f), 0.35f to c.copy(alpha = 0.34f), 0.68f to c.copy(alpha = 0.10f), 1.00f to Color.Transparent, center = center, radius = r, ), radius = r, center = center, blendMode = BlendMode.Plus, ) } .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onTap, ), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { Text( text = "\u2726", color = Color.White.copy(alpha = 0.92f), fontSize = 24.sp, fontWeight = FontWeight.Light, ) } } /** * Reparte las claves (ya ordenadas) en anillos. Cada anillo crece hacia afuera y su * capacidad depende de su circunferencia, así nada se amontona. */ private fun computePlacements( keys: List, firstR: Float, ringGap: Float, spacing: Float, ): List> { if (keys.isEmpty()) return emptyList() val result = ArrayList>(keys.size) var index = 0 var ring = 0 while (index < keys.size) { val radius = firstR + ring * ringGap val circumference = 2.0 * PI * radius val capacity = max(4, floor(circumference / spacing).toInt()) val count = min(capacity, keys.size - index) val base = ring * 0.5f for (j in 0 until count) { val angle = base + TAU_F * j / count result.add(keys[index + j] to Slot(ring, angle, radius)) } index += count ring++ } return result }