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.combinedClickable 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.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.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.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.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() /** * 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", ) // El núcleo va por su cuenta: una figura a la deriva (ver Nucleo.kt). El halo // y los anillos toman de ella su color y su brillo, para que todo respire igual. val nucleo = rememberNucleo() val forma: () -> FormaNucleo = { nucleo.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 f = forma() val c = colorDelCiclo(f.tinte) val b = f.brillo // El halo crece y mengua con la figura: cuanto más abierta y más viva, más lejos llega. val halo = min(wPx, hPx) * (0.46f + 0.16f * f.radio + 0.10f * b) drawCircle( brush = Brush.radialGradient( 0.00f to c.copy(alpha = 0.24f * b), 0.16f to c.copy(alpha = 0.125f * b), 0.38f to c.copy(alpha = 0.060f * b), 0.66f to c.copy(alpha = 0.020f * b), 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.09f + 0.07f * b), radius = firstR + r * ringGap, center = Offset(cx, cy), style = Stroke(width = 1.2f), ) } } // Núcleo: toque para buscar en el rizoma. Nucleo( modifier = Modifier.align(Alignment.Center), forma = forma, 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 } @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, ) } } /** * 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 }