⎇ 6f37139d565f0b409d34afbcb8c6f9f2bacdb3bc
launcher/src/main/java/com/rizoma/orbita/ui/OrbitLayout.kt
Ñawpa willay
ch'uya
fix: agregar imports faltantes (offset, getValue) y corregir CenterVertical/updateHaptics
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}