package com.rizoma.orbita.ui import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition 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.background import androidx.compose.foundation.combinedClickable 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.offset import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.material3.Text import androidx.compose.runtime.Composable 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.scale import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.drawscope.Stroke 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.IntOffset 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.roundToInt import kotlin.math.sin private data class Placement( val node: AppNode, val ring: Int, val angle: Double, val radius: Float, ) private val ringColors = listOf(NeonVerde, NeonRosa, NeonCian) /** * 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. */ @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") val spin by transition.animateFloat( initialValue = 0f, targetValue = (2 * PI).toFloat(), animationSpec = infiniteRepeatable(tween(180_000, easing = LinearEasing), RepeatMode.Restart), label = "spin", ) 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 placements = remember(nodes, wPx, hPx) { computePlacements(nodes, firstR, ringGap, spacing) } val rings = (placements.maxOfOrNull { it.ring } ?: -1) + 1 // Halo ambiental + anillos orbitales. Canvas(Modifier.fillMaxSize()) { drawCircle( brush = Brush.radialGradient( colors = listOf(NeonVerde.copy(alpha = 0.06f), androidx.compose.ui.graphics.Color.Transparent), center = Offset(cx, cy), radius = maxOf(wPx, hPx) * 0.6f, ), radius = maxOf(wPx, hPx) * 0.6f, center = Offset(cx, cy), ) for (r in 0 until rings) { drawCircle( color = ringColors[r % ringColors.size].copy(alpha = 0.12f), 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), onTap = onCenterTap, ) // Nodos en órbita. placements.forEach { p -> // Anillos contiguos contrarrotan. Factor entero (±1) → el bucle es continuo. val dir = if (p.ring % 2 == 0) 1f else -1f val angle = p.angle + spin * dir val x = cx + p.radius * cos(angle).toFloat() - colHalf val y = cy + p.radius * sin(angle).toFloat() - iconHalf NodeItem( node = p.node, modifier = Modifier.offset { IntOffset(x.roundToInt(), y.roundToInt()) }, onLaunch = onLaunch, onLongPress = onLongPress, ) } } } @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, ) } } @Composable private fun CenterHub(modifier: Modifier, onTap: () -> Unit) { val transition = rememberInfiniteTransition(label = "hub") val pulse by transition.animateFloat( initialValue = 0.88f, targetValue = 1.08f, animationSpec = infiniteRepeatable(tween(2600, easing = LinearEasing), RepeatMode.Reverse), label = "pulse", ) Column( modifier = modifier .size(76.dp) .scale(pulse) .clip(CircleShape) .background( Brush.radialGradient( listOf(NeonVerde.copy(alpha = 0.22f), androidx.compose.ui.graphics.Color.Transparent) ) ) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onTap, ), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { Text( text = "✦", color = NeonVerde, fontSize = 26.sp, fontWeight = FontWeight.Light, ) } } /** * Reparte los nodos (ya ordenados) en anillos. Cada anillo crece hacia afuera y su * capacidad depende de su circunferencia, así nada se amontona. */ private fun computePlacements( nodes: List, firstR: Float, ringGap: Float, spacing: Float, ): List { if (nodes.isEmpty()) return emptyList() val result = ArrayList(nodes.size) var index = 0 var ring = 0 while (index < nodes.size) { val radius = firstR + ring * ringGap val circumference = 2.0 * PI * radius val capacity = max(4, floor(circumference / spacing).toInt()) val count = min(capacity, nodes.size - index) val base = ring * 0.5 for (j in 0 until count) { val angle = base + 2.0 * PI * j / count result.add(Placement(nodes[index + j], ring, angle, radius)) } index += count ring++ } return result }