⎇ 4e0b0288593fde94ea257fc57545b1154e4493ea
launcher/src/main/java/com/rizoma/orbita/ui/Pages.kt
Historia
crudo
Órbita: cinta de 5 páginas — continuidad del átomo + zonas vivas
1package com.rizoma.orbita.ui
2
3import androidx.compose.animation.core.LinearEasing
4import androidx.compose.animation.core.animateFloat
5import androidx.compose.animation.core.infiniteRepeatable
6import androidx.compose.animation.core.rememberInfiniteTransition
7import androidx.compose.animation.core.tween
8import androidx.compose.foundation.Canvas
9import androidx.compose.foundation.ExperimentalFoundationApi
10import androidx.compose.foundation.Image
11import androidx.compose.foundation.combinedClickable
12import androidx.compose.foundation.clickable
13import androidx.compose.foundation.interaction.MutableInteractionSource
14import androidx.compose.foundation.layout.Arrangement
15import androidx.compose.foundation.layout.Box
16import androidx.compose.foundation.layout.BoxWithConstraints
17import androidx.compose.foundation.layout.BoxWithConstraintsScope
18import androidx.compose.foundation.layout.Column
19import androidx.compose.foundation.layout.Row
20import androidx.compose.foundation.layout.Spacer
21import androidx.compose.foundation.layout.WindowInsets
22import androidx.compose.foundation.layout.fillMaxSize
23import androidx.compose.foundation.layout.fillMaxWidth
24import androidx.compose.foundation.layout.height
25import androidx.compose.foundation.layout.padding
26import androidx.compose.foundation.layout.size
27import androidx.compose.foundation.layout.systemBars
28import androidx.compose.foundation.layout.width
29import androidx.compose.foundation.layout.windowInsetsPadding
30import androidx.compose.foundation.rememberScrollState
31import androidx.compose.foundation.shape.CircleShape
32import androidx.compose.foundation.verticalScroll
33import androidx.compose.material3.Text
34import androidx.compose.runtime.Composable
35import androidx.compose.runtime.getValue
36import androidx.compose.runtime.remember
37import androidx.compose.ui.Alignment
38import androidx.compose.ui.Modifier
39import androidx.compose.ui.draw.clip
40import androidx.compose.ui.geometry.Offset
41import androidx.compose.ui.graphics.Color
42import androidx.compose.ui.graphics.graphicsLayer
43import androidx.compose.ui.graphics.lerp
44import androidx.compose.ui.platform.LocalDensity
45import androidx.compose.ui.text.font.FontWeight
46import androidx.compose.ui.text.style.TextAlign
47import androidx.compose.ui.text.style.TextOverflow
48import androidx.compose.ui.unit.IntOffset
49import androidx.compose.ui.unit.dp
50import androidx.compose.ui.unit.sp
51import com.rizoma.orbita.data.Sky
52import com.rizoma.orbita.data.Weather
53import com.rizoma.orbita.model.AppNode
54import com.rizoma.orbita.model.ExpelledNode
55import com.rizoma.orbita.ui.theme.NeonCian
56import com.rizoma.orbita.ui.theme.NeonRosa
57import com.rizoma.orbita.ui.theme.NeonVerde
58import com.rizoma.orbita.ui.theme.Tenue
59import kotlin.math.PI
60import kotlin.math.abs
61import kotlin.math.cos
62import kotlin.math.roundToInt
63import kotlin.math.sin
64
65private const val TAU = (2 * PI).toFloat()
66private val MuyTenue = Color(0x66FFFFFF)
67
68// ─────────────────────────── Página 1: clima ───────────────────────────
69
70@Composable
71fun ClimaPage(weather: Weather?, nimboInstalled: Boolean, onOpenNimbo: () -> Unit) {
72 Box(
73 Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.systemBars).padding(28.dp),
74 contentAlignment = Alignment.Center,
75 ) {
76 Column(horizontalAlignment = Alignment.CenterHorizontally) {
77 Text("clima", color = MuyTenue, fontSize = 12.sp, letterSpacing = 4.sp)
78 Spacer(Modifier.height(20.dp))
79 if (weather == null) {
80 Text("leyendo el cielo…", color = Tenue, fontSize = 18.sp)
81 } else {
82 Text(
83 "${weather.temperatureC.roundToInt()}°",
84 color = Color.White, fontSize = 96.sp, fontWeight = FontWeight.Light,
85 )
86 Text(condicion(weather), color = Tenue, fontSize = 22.sp)
87 }
88 if (nimboInstalled) {
89 Spacer(Modifier.height(36.dp))
90 Text(
91 "abrir nimbo →",
92 color = NeonCian, fontSize = 14.sp, fontWeight = FontWeight.Medium,
93 modifier = Modifier
94 .clip(CircleShape)
95 .clickable(onClick = onOpenNimbo)
96 .padding(horizontal = 16.dp, vertical = 8.dp),
97 )
98 }
99 }
100 }
101}
102
103private fun condicion(w: Weather): String = when (w.sky) {
104 Sky.CLEAR -> if (w.isDay) "despejado" else "noche despejada"
105 Sky.CLOUDY -> "parcialmente nublado"
106 Sky.OVERCAST -> "cubierto"
107 Sky.FOG -> "niebla"
108 Sky.RAIN -> "lluvia"
109 Sky.SNOW -> "nieve"
110 Sky.STORM -> "tormenta"
111}
112
113// ─────────────────────── Página 2 (izq): nuevas ────────────────────────
114
115/** Apps recién instaladas, flotando caóticamente en la franja izquierda, esperando entrar. */
116@Composable
117fun NuevasZone(nodes: List<AppNode>, onLaunch: (AppNode) -> Unit, onLong: (AppNode) -> Unit) {
118 val time by rememberInfiniteTransition(label = "nuevas")
119 .animateFloat(0f, TAU, infiniteRepeatable(tween(24_000, easing = LinearEasing)), label = "t")
120 val density = LocalDensity.current
121
122 BoxWithConstraints(Modifier.fillMaxSize()) {
123 val w = with(density) { maxWidth.toPx() }
124 val h = with(density) { maxHeight.toPx() }
125 val iconHalf = with(density) { 26.dp.toPx() }
126
127 ZoneTitle("nuevas", "esperando entrar", NeonCian, Alignment.TopStart)
128 if (nodes.isEmpty()) ZoneEmpty("sin apps nuevas", Alignment.CenterStart)
129
130 nodes.forEach { node ->
131 val ph = seed(node.key, 1) * TAU
132 val fx = 0.55f + seed(node.key, 2) * 1.2f
133 val fy = 0.50f + seed(node.key, 3) * 1.1f
134 val ax = 0.05f + seed(node.key, 4) * 0.07f
135 val ay = 0.06f + seed(node.key, 5) * 0.08f
136 val ox = 0.16f + seed(node.key, 6) * 0.40f // franja izquierda (~16–56%)
137 val oy = 0.20f + seed(node.key, 7) * 0.58f
138 val px = w * (ox + ax * cos(time * fx + ph))
139 val py = h * (oy + ay * sin(time * fy + ph))
140 val tilt = sin(time * 0.6f + ph) * 9f
141 ZoneIcon(
142 node = node, sizeDp = 52, alpha = 1f, onLaunch = onLaunch, onLong = onLong,
143 modifier = Modifier
144 .offset { IntOffset((px - iconHalf).roundToInt(), (py - iconHalf).roundToInt()) }
145 .graphicsLayer { rotationZ = tilt },
146 )
147 }
148 }
149}
150
151// ─────────────────── Página 4 (der): expulsadas ────────────────────────
152
153/** Apps recién retiradas: se alborotan (jitter ∝ turbulencia) y se van calmando. */
154@Composable
155fun ExpulsadasZone(nodes: List<ExpelledNode>, onLaunch: (AppNode) -> Unit, onLong: (AppNode) -> Unit) {
156 val time by rememberInfiniteTransition(label = "expulsadas")
157 .animateFloat(0f, TAU, infiniteRepeatable(tween(9_000, easing = LinearEasing)), label = "t")
158 val density = LocalDensity.current
159
160 BoxWithConstraints(Modifier.fillMaxSize()) {
161 val w = with(density) { maxWidth.toPx() }
162 val h = with(density) { maxHeight.toPx() }
163 val iconHalf = with(density) { 24.dp.toPx() }
164
165 ZoneTitle("expulsadas", "se calman con el tiempo", NeonRosa, Alignment.TopEnd)
166 if (nodes.isEmpty()) ZoneEmpty("nada expulsado", Alignment.CenterEnd)
167
168 nodes.forEachIndexed { i, ex ->
169 val node = ex.node
170 val turb = ex.turbulence
171 val col = i % 3
172 val row = i / 3
173 val baseX = 0.48f + col * 0.17f // mitad derecha
174 val baseY = 0.24f + row * 0.14f
175 val ph = seed(node.key, 1) * TAU
176 val jx = turb * 0.045f * cos(time * 2.3f + ph)
177 val jy = turb * 0.055f * sin(time * 2.7f + ph * 1.3f)
178 val px = w * (baseX + jx)
179 val py = h * (baseY + jy)
180 ZoneIcon(
181 node = node, sizeDp = 48, alpha = 0.55f + 0.45f * turb,
182 onLaunch = onLaunch, onLong = onLong,
183 modifier = Modifier
184 .offset { IntOffset((px - iconHalf).roundToInt(), (py - iconHalf).roundToInt()) }
185 .graphicsLayer { rotationZ = turb * 10f * sin(time * 3f + ph) },
186 )
187 }
188 }
189}
190
191// ─────────────────────── Página 5: desterradas ─────────────────────────
192
193/** Apps expulsadas hace tiempo: ya quietas, en una cuadrícula calmada. */
194@Composable
195fun DesterradasZone(nodes: List<AppNode>, onLaunch: (AppNode) -> Unit, onLong: (AppNode) -> Unit) {
196 Column(
197 Modifier
198 .fillMaxSize()
199 .windowInsetsPadding(WindowInsets.systemBars)
200 .padding(horizontal = 24.dp)
201 .verticalScroll(rememberScrollState()),
202 ) {
203 Spacer(Modifier.height(20.dp))
204 Text("desterradas", color = NeonVerde, fontSize = 22.sp, fontWeight = FontWeight.SemiBold)
205 Text("expulsadas hace tiempo · ya quietas", color = MuyTenue, fontSize = 12.sp)
206 Spacer(Modifier.height(24.dp))
207
208 if (nodes.isEmpty()) {
209 Text("aún no hay apps desterradas", color = Tenue, fontSize = 15.sp)
210 }
211 nodes.chunked(4).forEach { fila ->
212 Row(Modifier.fillMaxWidth().padding(vertical = 10.dp)) {
213 fila.forEach { node ->
214 Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
215 ZoneIcon(node, sizeDp = 52, alpha = 0.5f, onLaunch = onLaunch, onLong = onLong, modifier = Modifier)
216 }
217 }
218 repeat(4 - fila.size) { Spacer(Modifier.weight(1f)) }
219 }
220 }
221 Spacer(Modifier.height(40.dp))
222 }
223}
224
225// ─────────────────────────── compartido ────────────────────────────────
226
227@OptIn(ExperimentalFoundationApi::class)
228@Composable
229private fun ZoneIcon(
230 node: AppNode,
231 sizeDp: Int,
232 alpha: Float,
233 onLaunch: (AppNode) -> Unit,
234 onLong: (AppNode) -> Unit,
235 modifier: Modifier,
236) {
237 Image(
238 bitmap = node.icon,
239 contentDescription = node.label,
240 alpha = alpha,
241 modifier = modifier
242 .size(sizeDp.dp)
243 .clip(CircleShape)
244 .combinedClickable(
245 onClick = { onLaunch(node) },
246 onLongClick = { onLong(node) },
247 ),
248 )
249}
250
251@Composable
252private fun BoxWithConstraintsScope.ZoneTitle(
253 title: String,
254 subtitle: String,
255 accent: Color,
256 align: Alignment,
257) {
258 Column(
259 Modifier
260 .align(align)
261 .windowInsetsPadding(WindowInsets.systemBars)
262 .padding(24.dp),
263 horizontalAlignment = if (align == Alignment.TopEnd) Alignment.End else Alignment.Start,
264 ) {
265 Text(title, color = accent, fontSize = 20.sp, fontWeight = FontWeight.SemiBold)
266 Text(subtitle, color = MuyTenue, fontSize = 12.sp)
267 }
268}
269
270@Composable
271private fun BoxWithConstraintsScope.ZoneEmpty(text: String, align: Alignment) {
272 Text(
273 text, color = Tenue, fontSize = 15.sp,
274 textAlign = TextAlign.Center,
275 maxLines = 2, overflow = TextOverflow.Ellipsis,
276 modifier = Modifier.align(align).padding(40.dp).width(160.dp),
277 )
278}
279
280/** Cinco puntos: el más cercano a la página actual brilla. Lee [progress] al dibujar. */
281@Composable
282fun PageDots(progress: () -> Float, modifier: Modifier) {
283 Canvas(modifier.fillMaxWidth().height(26.dp)) {
284 val p = progress()
285 val n = 5
286 val gap = 18.dp.toPx()
287 val startX = size.width / 2f - gap * (n - 1) / 2f
288 val cy = size.height / 2f
289 for (i in 0 until n) {
290 val d = (1f - abs(p - i).coerceIn(0f, 1f))
291 drawCircle(
292 color = lerp(MuyTenue, NeonVerde, d),
293 radius = (2.5f + 2.5f * d).dp.toPx(),
294 center = Offset(startX + i * gap, cy),
295 )
296 }
297 }
298}
299
300// Pseudoaleatorio estable por clave: misma app → mismos parámetros de movimiento.
301private fun seed(key: String, salt: Int): Float {
302 var h = key.hashCode() xor (salt * 0x27d4eb2d)
303 h *= 0x9E3779B1.toInt()
304 h = h xor (h ushr 15)
305 h *= 0x27d4eb2d
306 h = h xor (h ushr 13)
307 return ((h ushr 9) and 0x3FFFFF) / 4194303f
308}