tawasuyu

sergio/humanoid

HTML 50Markdown 1TOML 10 Sutichaykuna

⎇ 3feee86e320e76ed9ddd32f09ed18d20260c4e2a launcher/src/main/java/com/rizoma/orbita/ui/OrbitLayout.kt 249 siq'ikuna · 8.5 KB Ñawpa willay ch'uya

Órbita: launcher orbital anti-rejilla (v0.1) 3feee86e3 · sergio ·

1package com.rizoma.orbita.ui
2
3import androidx.compose.animation.core.LinearEasing
4import androidx.compose.animation.core.RepeatMode
5import androidx.compose.animation.core.animateFloat
6import androidx.compose.animation.core.infiniteRepeatable
7import androidx.compose.animation.core.rememberInfiniteTransition
8import androidx.compose.animation.core.tween
9import androidx.compose.foundation.Canvas
10import androidx.compose.foundation.ExperimentalFoundationApi
11import androidx.compose.foundation.Image
12import androidx.compose.foundation.background
13import androidx.compose.foundation.combinedClickable
14import androidx.compose.foundation.layout.Arrangement
15import androidx.compose.foundation.layout.BoxWithConstraints
16import androidx.compose.foundation.layout.Column
17import androidx.compose.foundation.layout.fillMaxSize
18import androidx.compose.foundation.layout.offset
19import androidx.compose.foundation.layout.size
20import androidx.compose.foundation.layout.width
21import androidx.compose.foundation.shape.CircleShape
22import androidx.compose.foundation.border
23import androidx.compose.foundation.clickable
24import androidx.compose.foundation.interaction.MutableInteractionSource
25import androidx.compose.material3.Text
26import androidx.compose.runtime.Composable
27import androidx.compose.runtime.remember
28import androidx.compose.ui.Alignment
29import androidx.compose.ui.Modifier
30import androidx.compose.ui.draw.clip
31import androidx.compose.ui.draw.scale
32import androidx.compose.ui.geometry.Offset
33import androidx.compose.ui.graphics.Brush
34import androidx.compose.ui.graphics.drawscope.Stroke
35import androidx.compose.ui.platform.LocalDensity
36import androidx.compose.ui.text.font.FontWeight
37import androidx.compose.ui.text.style.TextAlign
38import androidx.compose.ui.text.style.TextOverflow
39import androidx.compose.ui.unit.IntOffset
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.roundToInt
53import kotlin.math.sin
54
55private data class Placement(
56    val node: AppNode,
57    val ring: Int,
58    val angle: Double,
59    val radius: Float,
60)
61
62private val ringColors = listOf(NeonVerde, NeonRosa, NeonCian)
63
64/**
65 * Distribución anti-rejilla: los nodos caen en órbitas concéntricas según su calor.
66 * Las órbitas internas (más cerca del centro) son las apps más usadas y las fijadas.
67 */
68@OptIn(ExperimentalFoundationApi::class)
69@Composable
70fun OrbitLayout(
71    nodes: List<AppNode>,
72    onLaunch: (AppNode) -> Unit,
73    onLongPress: (AppNode) -> Unit,
74    onCenterTap: () -> Unit,
75    modifier: Modifier = Modifier,
76) {
77    val density = LocalDensity.current
78    val transition = rememberInfiniteTransition(label = "orbita")
79    val spin by transition.animateFloat(
80        initialValue = 0f,
81        targetValue = (2 * PI).toFloat(),
82        animationSpec = infiniteRepeatable(tween(180_000, easing = LinearEasing), RepeatMode.Restart),
83        label = "spin",
84    )
85
86    BoxWithConstraints(modifier.fillMaxSize()) {
87        val wPx = with(density) { maxWidth.toPx() }
88        val hPx = with(density) { maxHeight.toPx() }
89        val cx = wPx / 2f
90        val cy = hPx / 2f
91
92        val firstR = with(density) { 116.dp.toPx() }
93        val ringGap = with(density) { 86.dp.toPx() }
94        val spacing = with(density) { 76.dp.toPx() }
95        val colHalf = with(density) { 32.dp.toPx() }
96        val iconHalf = with(density) { 27.dp.toPx() }
97
98        val placements = remember(nodes, wPx, hPx) {
99            computePlacements(nodes, firstR, ringGap, spacing)
100        }
101        val rings = (placements.maxOfOrNull { it.ring } ?: -1) + 1
102
103        // Halo ambiental + anillos orbitales.
104        Canvas(Modifier.fillMaxSize()) {
105            drawCircle(
106                brush = Brush.radialGradient(
107                    colors = listOf(NeonVerde.copy(alpha = 0.06f), androidx.compose.ui.graphics.Color.Transparent),
108                    center = Offset(cx, cy),
109                    radius = maxOf(wPx, hPx) * 0.6f,
110                ),
111                radius = maxOf(wPx, hPx) * 0.6f,
112                center = Offset(cx, cy),
113            )
114            for (r in 0 until rings) {
115                drawCircle(
116                    color = ringColors[r % ringColors.size].copy(alpha = 0.12f),
117                    radius = firstR + r * ringGap,
118                    center = Offset(cx, cy),
119                    style = Stroke(width = 1.2f),
120                )
121            }
122        }
123
124        // Núcleo: toque para buscar en el rizoma.
125        CenterHub(
126            modifier = Modifier.align(Alignment.Center),
127            onTap = onCenterTap,
128        )
129
130        // Nodos en órbita.
131        placements.forEach { p ->
132            // Anillos contiguos contrarrotan. Factor entero (±1) → el bucle es continuo.
133            val dir = if (p.ring % 2 == 0) 1f else -1f
134            val angle = p.angle + spin * dir
135            val x = cx + p.radius * cos(angle).toFloat() - colHalf
136            val y = cy + p.radius * sin(angle).toFloat() - iconHalf
137            NodeItem(
138                node = p.node,
139                modifier = Modifier.offset { IntOffset(x.roundToInt(), y.roundToInt()) },
140                onLaunch = onLaunch,
141                onLongPress = onLongPress,
142            )
143        }
144    }
145}
146
147@OptIn(ExperimentalFoundationApi::class)
148@Composable
149private fun NodeItem(
150    node: AppNode,
151    modifier: Modifier,
152    onLaunch: (AppNode) -> Unit,
153    onLongPress: (AppNode) -> Unit,
154) {
155    Column(
156        modifier = modifier.width(64.dp),
157        horizontalAlignment = Alignment.CenterHorizontally,
158    ) {
159        val iconMod = Modifier
160            .size(54.dp)
161            .clip(CircleShape)
162            .combinedClickable(
163                onClick = { onLaunch(node) },
164                onLongClick = { onLongPress(node) },
165            )
166            .then(
167                if (node.pinned) Modifier.border(1.5.dp, NeonRosa, CircleShape) else Modifier
168            )
169        Image(
170            bitmap = node.icon,
171            contentDescription = node.label,
172            modifier = iconMod,
173        )
174        Text(
175            text = node.label,
176            color = Tenue,
177            fontSize = 10.sp,
178            maxLines = 1,
179            overflow = TextOverflow.Ellipsis,
180            textAlign = TextAlign.Center,
181        )
182    }
183}
184
185@Composable
186private fun CenterHub(modifier: Modifier, onTap: () -> Unit) {
187    val transition = rememberInfiniteTransition(label = "hub")
188    val pulse by transition.animateFloat(
189        initialValue = 0.88f,
190        targetValue = 1.08f,
191        animationSpec = infiniteRepeatable(tween(2600, easing = LinearEasing), RepeatMode.Reverse),
192        label = "pulse",
193    )
194    Column(
195        modifier = modifier
196            .size(76.dp)
197            .scale(pulse)
198            .clip(CircleShape)
199            .background(
200                Brush.radialGradient(
201                    listOf(NeonVerde.copy(alpha = 0.22f), androidx.compose.ui.graphics.Color.Transparent)
202                )
203            )
204            .clickable(
205                interactionSource = remember { MutableInteractionSource() },
206                indication = null,
207                onClick = onTap,
208            ),
209        horizontalAlignment = Alignment.CenterHorizontally,
210        verticalArrangement = Arrangement.Center,
211    ) {
212        Text(
213            text = "✦",
214            color = NeonVerde,
215            fontSize = 26.sp,
216            fontWeight = FontWeight.Light,
217        )
218    }
219}
220
221/**
222 * Reparte los nodos (ya ordenados) en anillos. Cada anillo crece hacia afuera y su
223 * capacidad depende de su circunferencia, así nada se amontona.
224 */
225private fun computePlacements(
226    nodes: List<AppNode>,
227    firstR: Float,
228    ringGap: Float,
229    spacing: Float,
230): List<Placement> {
231    if (nodes.isEmpty()) return emptyList()
232    val result = ArrayList<Placement>(nodes.size)
233    var index = 0
234    var ring = 0
235    while (index < nodes.size) {
236        val radius = firstR + ring * ringGap
237        val circumference = 2.0 * PI * radius
238        val capacity = max(4, floor(circumference / spacing).toInt())
239        val count = min(capacity, nodes.size - index)
240        val base = ring * 0.5
241        for (j in 0 until count) {
242            val angle = base + 2.0 * PI * j / count
243            result.add(Placement(nodes[index + j], ring, angle, radius))
244        }
245        index += count
246        ring++
247    }
248    return result
249}