tawasuyu

sergio/humanoid

HTML 50Markdown 1TOML 10 Sutichaykuna

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

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