package com.rizoma.orbita.ui import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.BoxWithConstraintsScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.lerp import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.rizoma.orbita.data.Sky import com.rizoma.orbita.data.Weather import com.rizoma.orbita.model.AppNode import com.rizoma.orbita.model.ExpelledNode import com.rizoma.orbita.ui.theme.NeonCian import com.rizoma.orbita.ui.theme.NeonRosa import com.rizoma.orbita.ui.theme.NeonVerde import com.rizoma.orbita.ui.theme.Tenue import kotlin.math.PI import kotlin.math.abs import kotlin.math.cos import kotlin.math.roundToInt import kotlin.math.sin private const val TAU = (2 * PI).toFloat() private val MuyTenue = Color(0x66FFFFFF) // ─────────────────────────── Página 1: clima ─────────────────────────── @Composable fun ClimaPage(weather: Weather?, nimboInstalled: Boolean, onOpenNimbo: () -> Unit) { Box( Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.systemBars).padding(28.dp), contentAlignment = Alignment.Center, ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text("clima", color = MuyTenue, fontSize = 12.sp, letterSpacing = 4.sp) Spacer(Modifier.height(20.dp)) if (weather == null) { Text("leyendo el cielo…", color = Tenue, fontSize = 18.sp) } else { Text( "${weather.temperatureC.roundToInt()}°", color = Color.White, fontSize = 96.sp, fontWeight = FontWeight.Light, ) Text(condicion(weather), color = Tenue, fontSize = 22.sp) } if (nimboInstalled) { Spacer(Modifier.height(36.dp)) Text( "abrir nimbo →", color = NeonCian, fontSize = 14.sp, fontWeight = FontWeight.Medium, modifier = Modifier .clip(CircleShape) .clickable(onClick = onOpenNimbo) .padding(horizontal = 16.dp, vertical = 8.dp), ) } } } } private fun condicion(w: Weather): String = when (w.sky) { Sky.CLEAR -> if (w.isDay) "despejado" else "noche despejada" Sky.CLOUDY -> "parcialmente nublado" Sky.OVERCAST -> "cubierto" Sky.FOG -> "niebla" Sky.RAIN -> "lluvia" Sky.SNOW -> "nieve" Sky.STORM -> "tormenta" } // ─────────────────────── Página 2 (izq): nuevas ──────────────────────── /** Apps recién instaladas, flotando caóticamente en la franja izquierda, esperando entrar. */ @Composable fun NuevasZone(nodes: List, onLaunch: (AppNode) -> Unit, onLong: (AppNode) -> Unit) { val time by rememberInfiniteTransition(label = "nuevas") .animateFloat(0f, TAU, infiniteRepeatable(tween(24_000, easing = LinearEasing)), label = "t") val density = LocalDensity.current BoxWithConstraints(Modifier.fillMaxSize()) { val w = with(density) { maxWidth.toPx() } val h = with(density) { maxHeight.toPx() } val iconHalf = with(density) { 26.dp.toPx() } ZoneTitle("nuevas", "esperando entrar", NeonCian, Alignment.TopStart) if (nodes.isEmpty()) ZoneEmpty("sin apps nuevas", Alignment.CenterStart) nodes.forEach { node -> val ph = seed(node.key, 1) * TAU val fx = 0.55f + seed(node.key, 2) * 1.2f val fy = 0.50f + seed(node.key, 3) * 1.1f val ax = 0.05f + seed(node.key, 4) * 0.07f val ay = 0.06f + seed(node.key, 5) * 0.08f val ox = 0.16f + seed(node.key, 6) * 0.40f // franja izquierda (~16–56%) val oy = 0.20f + seed(node.key, 7) * 0.58f val px = w * (ox + ax * cos(time * fx + ph)) val py = h * (oy + ay * sin(time * fy + ph)) val tilt = sin(time * 0.6f + ph) * 9f ZoneIcon( node = node, sizeDp = 52, alpha = 1f, onLaunch = onLaunch, onLong = onLong, modifier = Modifier .offset { IntOffset((px - iconHalf).roundToInt(), (py - iconHalf).roundToInt()) } .graphicsLayer { rotationZ = tilt }, ) } } } // ─────────────────── Página 4 (der): expulsadas ──────────────────────── /** Apps recién retiradas: se alborotan (jitter ∝ turbulencia) y se van calmando. */ @Composable fun ExpulsadasZone(nodes: List, onLaunch: (AppNode) -> Unit, onLong: (AppNode) -> Unit) { val time by rememberInfiniteTransition(label = "expulsadas") .animateFloat(0f, TAU, infiniteRepeatable(tween(9_000, easing = LinearEasing)), label = "t") val density = LocalDensity.current BoxWithConstraints(Modifier.fillMaxSize()) { val w = with(density) { maxWidth.toPx() } val h = with(density) { maxHeight.toPx() } val iconHalf = with(density) { 24.dp.toPx() } ZoneTitle("expulsadas", "se calman con el tiempo", NeonRosa, Alignment.TopEnd) if (nodes.isEmpty()) ZoneEmpty("nada expulsado", Alignment.CenterEnd) nodes.forEachIndexed { i, ex -> val node = ex.node val turb = ex.turbulence val col = i % 3 val row = i / 3 val baseX = 0.48f + col * 0.17f // mitad derecha val baseY = 0.24f + row * 0.14f val ph = seed(node.key, 1) * TAU val jx = turb * 0.045f * cos(time * 2.3f + ph) val jy = turb * 0.055f * sin(time * 2.7f + ph * 1.3f) val px = w * (baseX + jx) val py = h * (baseY + jy) ZoneIcon( node = node, sizeDp = 48, alpha = 0.55f + 0.45f * turb, onLaunch = onLaunch, onLong = onLong, modifier = Modifier .offset { IntOffset((px - iconHalf).roundToInt(), (py - iconHalf).roundToInt()) } .graphicsLayer { rotationZ = turb * 10f * sin(time * 3f + ph) }, ) } } } // ─────────────────────── Página 5: desterradas ───────────────────────── /** Apps expulsadas hace tiempo: ya quietas, en una cuadrícula calmada. */ @Composable fun DesterradasZone(nodes: List, onLaunch: (AppNode) -> Unit, onLong: (AppNode) -> Unit) { Column( Modifier .fillMaxSize() .windowInsetsPadding(WindowInsets.systemBars) .padding(horizontal = 24.dp) .verticalScroll(rememberScrollState()), ) { Spacer(Modifier.height(20.dp)) Text("desterradas", color = NeonVerde, fontSize = 22.sp, fontWeight = FontWeight.SemiBold) Text("expulsadas hace tiempo · ya quietas", color = MuyTenue, fontSize = 12.sp) Spacer(Modifier.height(24.dp)) if (nodes.isEmpty()) { Text("aún no hay apps desterradas", color = Tenue, fontSize = 15.sp) } nodes.chunked(4).forEach { fila -> Row(Modifier.fillMaxWidth().padding(vertical = 10.dp)) { fila.forEach { node -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { ZoneIcon(node, sizeDp = 52, alpha = 0.5f, onLaunch = onLaunch, onLong = onLong, modifier = Modifier) } } repeat(4 - fila.size) { Spacer(Modifier.weight(1f)) } } } Spacer(Modifier.height(40.dp)) } } // ─────────────────────────── compartido ──────────────────────────────── @OptIn(ExperimentalFoundationApi::class) @Composable private fun ZoneIcon( node: AppNode, sizeDp: Int, alpha: Float, onLaunch: (AppNode) -> Unit, onLong: (AppNode) -> Unit, modifier: Modifier, ) { Image( bitmap = node.icon, contentDescription = node.label, alpha = alpha, modifier = modifier .size(sizeDp.dp) .clip(CircleShape) .combinedClickable( onClick = { onLaunch(node) }, onLongClick = { onLong(node) }, ), ) } @Composable private fun BoxWithConstraintsScope.ZoneTitle( title: String, subtitle: String, accent: Color, align: Alignment, ) { Column( Modifier .align(align) .windowInsetsPadding(WindowInsets.systemBars) .padding(24.dp), horizontalAlignment = if (align == Alignment.TopEnd) Alignment.End else Alignment.Start, ) { Text(title, color = accent, fontSize = 20.sp, fontWeight = FontWeight.SemiBold) Text(subtitle, color = MuyTenue, fontSize = 12.sp) } } @Composable private fun BoxWithConstraintsScope.ZoneEmpty(text: String, align: Alignment) { Text( text, color = Tenue, fontSize = 15.sp, textAlign = TextAlign.Center, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.align(align).padding(40.dp).width(160.dp), ) } /** Cinco puntos: el más cercano a la página actual brilla. Lee [progress] al dibujar. */ @Composable fun PageDots(progress: () -> Float, modifier: Modifier = Modifier) { Canvas(modifier.fillMaxWidth().height(26.dp)) { val p = progress() val n = 5 val gap = 18.dp.toPx() val startX = size.width / 2f - gap * (n - 1) / 2f val cy = size.height / 2f for (i in 0 until n) { val d = (1f - abs(p - i).coerceIn(0f, 1f)) drawCircle( color = lerp(MuyTenue, NeonVerde, d), radius = (2.5f + 2.5f * d).dp.toPx(), center = Offset(startX + i * gap, cy), ) } } } // Pseudoaleatorio estable por clave: misma app → mismos parámetros de movimiento. private fun seed(key: String, salt: Int): Float { var h = key.hashCode() xor (salt * 0x27d4eb2d) h *= 0x9E3779B1.toInt() h = h xor (h ushr 15) h *= 0x27d4eb2d h = h xor (h ushr 13) return ((h ushr 9) and 0x3FFFFF) / 4194303f }