⎇ 6f37139d565f0b409d34afbcb8c6f9f2bacdb3bc
launcher/src/main/java/com/rizoma/orbita/ui/Pages.kt
Historia
crudo
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.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}