tawasuyu

sergio/humanoid

HTML 38Markdown 1TOML 10 Definiciones

⎇ 4e0b0288593fde94ea257fc57545b1154e4493ea launcher/src/main/java/com/rizoma/orbita/ui/Pages.kt 308 líneas · 12.4 KB Historia crudo

Órbita: cinta de 5 páginas — continuidad del átomo + zonas vivas 4e0b02885 · sergio ·

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}