gioser
⎇ a4998fe44b630e7fbb929875d300c82de41ca981 launcher/src/main/java/com/rizoma/orbita/ui/OrbitLayout.kt 295 siq'ikuna · 11.3 KB Ñawpa willay ch'uya

Órbita: el núcleo es una figura viva, no un punto a4998fe44 · Sergio ·

1package com.rizoma.orbita.ui
2
3import androidx.compose.animation.core.LinearEasing
4import androidx.compose.animation.core.RepeatMode
5import androidx.compose.animation.core.Spring
6import androidx.compose.animation.core.animateFloat
7import androidx.compose.animation.core.animateFloatAsState
8import androidx.compose.animation.core.infiniteRepeatable
9import androidx.compose.animation.core.rememberInfiniteTransition
10import androidx.compose.animation.core.spring
11import androidx.compose.animation.core.tween
12import androidx.compose.foundation.Canvas
13import androidx.compose.foundation.ExperimentalFoundationApi
14import androidx.compose.foundation.Image
15import androidx.compose.foundation.border
16import androidx.compose.foundation.combinedClickable
17import androidx.compose.foundation.layout.BoxWithConstraints
18import androidx.compose.foundation.layout.Column
19import androidx.compose.foundation.layout.fillMaxSize
20import androidx.compose.foundation.layout.size
21import androidx.compose.foundation.layout.width
22import androidx.compose.foundation.shape.CircleShape
23import androidx.compose.material3.Text
24import androidx.compose.runtime.Composable
25import androidx.compose.runtime.key
26import androidx.compose.runtime.remember
27import androidx.compose.ui.Alignment
28import androidx.compose.ui.Modifier
29import androidx.compose.ui.draw.clip
30import androidx.compose.ui.geometry.Offset
31import androidx.compose.ui.graphics.BlendMode
32import androidx.compose.ui.graphics.Brush
33import androidx.compose.ui.graphics.Color
34import androidx.compose.ui.graphics.drawscope.Stroke
35import androidx.compose.ui.graphics.graphicsLayer
36import androidx.compose.ui.graphics.lerp
37import androidx.compose.ui.platform.LocalDensity
38import androidx.compose.ui.text.style.TextAlign
39import androidx.compose.ui.text.style.TextOverflow
40import androidx.compose.ui.unit.dp
41import androidx.compose.ui.unit.sp
42import com.rizoma.orbita.model.AppNode
43import com.rizoma.orbita.ui.theme.NeonCian
44import com.rizoma.orbita.ui.theme.NeonRosa
45import com.rizoma.orbita.ui.theme.NeonVerde
46import com.rizoma.orbita.ui.theme.Tenue
47import kotlin.math.PI
48import kotlin.math.cos
49import kotlin.math.floor
50import kotlin.math.max
51import kotlin.math.min
52import kotlin.math.sin
53
54/** La casilla orbital de un nodo: en qué anillo cae y con qué ángulo de partida. */
55private data class Slot(
56    val ring: Int,
57    val angle: Float,
58    val radius: Float,
59)
60
61/** El reparto entero: una casilla por clave de nodo, más cuántos anillos ocupa. */
62private data class Orbitas(val slots: Map<String, Slot>, val rings: Int)
63
64private val ringColors = listOf(NeonVerde, NeonRosa, NeonCian)
65private const val TAU_F = (2 * PI).toFloat()
66
67/**
68 * Distribución anti-rejilla: los nodos caen en órbitas concéntricas según su calor.
69 * Las órbitas internas (más cerca del centro) son las apps más usadas y las fijadas.
70 *
71 * Dos reglas mantienen la ronda estable (nada de saltos):
72 *  - el reparto en casillas se congela: el orden por calor decide la primera vez,
73 *    y a partir de ahí un nodo conserva su casilla aunque su calor cambie al usarlo;
74 *  - la posición se calcula en la fase de dibujo (graphicsLayer), no recomponiendo:
75 *    la vuelta va a 60 fps sin depender de que Compose recomponga cada fotograma.
76 */
77@OptIn(ExperimentalFoundationApi::class)
78@Composable
79fun OrbitLayout(
80    nodes: List<AppNode>,
81    onLaunch: (AppNode) -> Unit,
82    onLongPress: (AppNode) -> Unit,
83    onCenterTap: () -> Unit,
84    modifier: Modifier = Modifier,
85) {
86    val density = LocalDensity.current
87    val transition = rememberInfiniteTransition(label = "orbita")
88    // Sin `by`: el State se lee dentro de lambdas de dibujo, así el giro no recompone.
89    val spin = transition.animateFloat(
90        initialValue = 0f,
91        targetValue = TAU_F,
92        animationSpec = infiniteRepeatable(tween(180_000, easing = LinearEasing), RepeatMode.Restart),
93        label = "spin",
94    )
95    // El núcleo va por su cuenta: una figura a la deriva (ver Nucleo.kt). El halo
96    // y los anillos toman de ella su color y su brillo, para que todo respire igual.
97    val nucleo = rememberNucleo()
98    val forma: () -> FormaNucleo = { nucleo.value }
99
100    BoxWithConstraints(modifier.fillMaxSize()) {
101        val wPx = with(density) { maxWidth.toPx() }
102        val hPx = with(density) { maxHeight.toPx() }
103        val cx = wPx / 2f
104        val cy = hPx / 2f
105
106        val firstR = with(density) { 116.dp.toPx() }
107        val ringGap = with(density) { 86.dp.toPx() }
108        val spacing = with(density) { 76.dp.toPx() }
109        val colHalf = with(density) { 32.dp.toPx() }
110        val iconHalf = with(density) { 27.dp.toPx() }
111
112        val orbitas = rememberOrbitas(nodes, firstR, ringGap, spacing)
113        val rings = orbitas.rings
114
115        // Halo del núcleo (mucho alcance, muy difuminado) + anillos orbitales.
116        Canvas(Modifier.fillMaxSize()) {
117            val f = forma()
118            val c = colorDelCiclo(f.tinte)
119            val b = f.brillo
120            // El halo crece y mengua con la figura: cuanto más abierta y más viva, más lejos llega.
121            val halo = min(wPx, hPx) * (0.46f + 0.16f * f.radio + 0.10f * b)
122            drawCircle(
123                brush = Brush.radialGradient(
124                    0.00f to c.copy(alpha = 0.24f * b),
125                    0.16f to c.copy(alpha = 0.125f * b),
126                    0.38f to c.copy(alpha = 0.060f * b),
127                    0.66f to c.copy(alpha = 0.020f * b),
128                    1.00f to Color.Transparent,
129                    center = Offset(cx, cy),
130                    radius = halo,
131                ),
132                radius = halo,
133                center = Offset(cx, cy),
134                blendMode = BlendMode.Plus,
135            )
136            for (r in 0 until rings) {
137                // Los anillos se tiñen a medias con el color vivo del núcleo.
138                drawCircle(
139                    color = lerp(ringColors[r % ringColors.size], c, 0.5f)
140                        .copy(alpha = 0.09f + 0.07f * b),
141                    radius = firstR + r * ringGap,
142                    center = Offset(cx, cy),
143                    style = Stroke(width = 1.2f),
144                )
145            }
146        }
147
148        // Núcleo: toque para buscar en el rizoma.
149        Nucleo(
150            modifier = Modifier.align(Alignment.Center),
151            forma = forma,
152            onTap = onCenterTap,
153        )
154
155        // Nodos en órbita. Si una casilla cambia (se instala o se expulsa una app),
156        // el icono viaja hasta ella con un muelle lento: nunca reaparece en otro sitio.
157        nodes.forEach { node ->
158            val slot = orbitas.slots[node.key] ?: return@forEach
159            key(node.key) {
160                val angle = animateFloatAsState(
161                    targetValue = slot.angle,
162                    animationSpec = spring(dampingRatio = 1f, stiffness = Spring.StiffnessVeryLow),
163                    label = "ang",
164                )
165                val radius = animateFloatAsState(
166                    targetValue = slot.radius,
167                    animationSpec = spring(dampingRatio = 1f, stiffness = Spring.StiffnessVeryLow),
168                    label = "rad",
169                )
170                // Anillos contiguos contrarrotan. Factor entero (±1) → el bucle es continuo.
171                val dir = if (slot.ring % 2 == 0) 1f else -1f
172                NodeItem(
173                    node = node,
174                    modifier = Modifier
175                        .align(Alignment.TopStart)
176                        .graphicsLayer {
177                            val a = angle.value + spin.value * dir
178                            translationX = cx + radius.value * cos(a) - colHalf
179                            translationY = cy + radius.value * sin(a) - iconHalf
180                        },
181                    onLaunch = onLaunch,
182                    onLongPress = onLongPress,
183                )
184            }
185        }
186    }
187}
188
189/**
190 * Casillas estables. El orden solo se recalcula cuando cambia la *firma* del átomo:
191 * qué apps hay, cuáles están fijadas y cuáles alejadas. El calor sube al usar una app,
192 * pero no mueve nada por su cuenta — el átomo se reordena cuando tú actúas sobre él,
193 * nunca a tus espaldas mientras miras la ronda. Y aun entonces, cada icono viaja a su
194 * nueva casilla con un muelle lento: nunca reaparece de golpe en otro sitio.
195 */
196@Composable
197private fun rememberOrbitas(
198    nodes: List<AppNode>,
199    firstR: Float,
200    ringGap: Float,
201    spacing: Float,
202): Orbitas {
203    val anguloPrevio = remember { HashMap<String, Float>() }
204    val firma = nodes.joinToString("|") { "${it.key}#${if (it.pinned) 1 else 0}#${it.penalty}" }
205    return remember(firma, firstR, ringGap, spacing) {
206        val slots = HashMap<String, Slot>(nodes.size)
207        var rings = 0
208        computePlacements(nodes.map { it.key }, firstR, ringGap, spacing).forEach { (clave, slot) ->
209            // El ángulo se "desenrolla" respecto al anterior para que la animación
210            // tome el camino corto y no dé una vuelta entera de más.
211            val previo = anguloPrevio[clave]
212            val angulo = if (previo == null) slot.angle else desenrollar(previo, slot.angle)
213            anguloPrevio[clave] = angulo
214            slots[clave] = slot.copy(angle = angulo)
215            rings = max(rings, slot.ring + 1)
216        }
217        anguloPrevio.keys.retainAll(slots.keys)
218        Orbitas(slots, rings)
219    }
220}
221
222private fun desenrollar(previo: Float, destino: Float): Float {
223    var a = destino
224    while (a - previo > TAU_F / 2f) a -= TAU_F
225    while (previo - a > TAU_F / 2f) a += TAU_F
226    return a
227}
228
229@OptIn(ExperimentalFoundationApi::class)
230@Composable
231private fun NodeItem(
232    node: AppNode,
233    modifier: Modifier,
234    onLaunch: (AppNode) -> Unit,
235    onLongPress: (AppNode) -> Unit,
236) {
237    Column(
238        modifier = modifier.width(64.dp),
239        horizontalAlignment = Alignment.CenterHorizontally,
240    ) {
241        val iconMod = Modifier
242            .size(54.dp)
243            .clip(CircleShape)
244            .combinedClickable(
245                onClick = { onLaunch(node) },
246                onLongClick = { onLongPress(node) },
247            )
248            .then(
249                if (node.pinned) Modifier.border(1.5.dp, NeonRosa, CircleShape) else Modifier
250            )
251        Image(
252            bitmap = node.icon,
253            contentDescription = node.label,
254            modifier = iconMod,
255        )
256        Text(
257            text = node.label,
258            color = Tenue,
259            fontSize = 10.sp,
260            maxLines = 1,
261            overflow = TextOverflow.Ellipsis,
262            textAlign = TextAlign.Center,
263        )
264    }
265}
266
267/**
268 * Reparte las claves (ya ordenadas) en anillos. Cada anillo crece hacia afuera y su
269 * capacidad depende de su circunferencia, así nada se amontona.
270 */
271private fun computePlacements(
272    keys: List<String>,
273    firstR: Float,
274    ringGap: Float,
275    spacing: Float,
276): List<Pair<String, Slot>> {
277    if (keys.isEmpty()) return emptyList()
278    val result = ArrayList<Pair<String, Slot>>(keys.size)
279    var index = 0
280    var ring = 0
281    while (index < keys.size) {
282        val radius = firstR + ring * ringGap
283        val circumference = 2.0 * PI * radius
284        val capacity = max(4, floor(circumference / spacing).toInt())
285        val count = min(capacity, keys.size - index)
286        val base = ring * 0.5f
287        for (j in 0 until count) {
288            val angle = base + TAU_F * j / count
289            result.add(keys[index + j] to Slot(ring, angle, radius))
290        }
291        index += count
292        ring++
293    }
294    return result
295}