tawasuyu

sergio/humanoid

HTML 50Markdown 1TOML 10 Definiciones

⎇ 6f37139d565f0b409d34afbcb8c6f9f2bacdb3bc launcher/src/main/java/com/rizoma/orbita/ui/Pages.kt 309 líneas · 12.4 KB Historia crudo

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