⎇ 3feee86e320e76ed9ddd32f09ed18d20260c4e2a
launcher/src/main/java/com/rizoma/orbita/ui/OrbitLayout.kt
Ñawpa willay
ch'uya
Órbita: launcher orbital anti-rejilla (v0.1)
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}