tawasuyu

sergio/humanoid

HTML 50Markdown 1TOML 10 Definiciones

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

El launcher pasa de una sola vista a cinco páginas que se arrastran de izquierda
a derecha; el átomo es UNA pieza que se desliza tras ellas (continuidad real),
no cinco copias. Páginas: 1.clima · 2.nuevas+pedazo · 3.centro del átomo ·
4.pedazo+expulsadas · 5.desterradas.

- Paginado continuo propio (pos 0..4) con draggable + animate/spring; el cuerpo
  no se recompone por fotograma (graphicsLayer/derivedStateOf leen pos diferido).
- nuevas: apps recién instaladas (<3 días, sin abrir) flotando caóticamente
  "esperando entrar"; al abrirlas entran al átomo.
- expulsadas: las quitadas del átomo se alborotan (jitter ∝ turbulencia) y se
  calman con el tiempo; a los 7 días pasan a desterradas (ya quietas, página 5).
  AppNode gana firstInstallTime y hiddenAt; UsageStore sella el instante de
  expulsión (snapshot() unifica toda la memoria orbital en una lectura).
- clima (página 1): temperatura+condición sobre el cielo vivo; abre Nimbo si está.
- Optimización: cada zona se compone solo cerca de su página y el clima solo se
  pide (permiso+red) al acercarse — mientras no se abra, no se carga.

Escrito NO compilado aquí.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

4e0b0288593fde94ea257fc57545b1154e4493ea · por sergio · (hace 4 meses) · padre a116b96ab · ver el árbol en este commit

Delta semántico

Ninguna definición cambió: el cambio es de texto, documentación o datos.

7 archivos · +584 −57

diff --git a/launcher/src/main/java/com/rizoma/orbita/data/AppRepository.kt b/launcher/src/main/java/com/rizoma/orbita/data/AppRepository.kt
index 83bc016..cc55e29 100644
--- a/launcher/src/main/java/com/rizoma/orbita/data/AppRepository.kt
+++ b/launcher/src/main/java/com/rizoma/orbita/data/AppRepository.kt
@@ -18,16 +18,23 @@ class AppRepository(context: Context) {
     fun loadApps(): List<AppNode> {
         val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
         val self = appContext.packageName
+        // Fecha de instalación por paquete (cara de consultar): se cachea, ya que
+        // un mismo paquete puede aportar varias actividades lanzables.
+        val installed = HashMap<String, Long>()
         return pm.queryIntentActivities(intent, 0)
             .asSequence()
             .filter { it.activityInfo.packageName != self }
             .map { ri ->
+                val pkg = ri.activityInfo.packageName
                 val icon = ri.loadIcon(pm).toBitmap(ICON_PX, ICON_PX).asImageBitmap()
                 AppNode(
-                    packageName = ri.activityInfo.packageName,
+                    packageName = pkg,
                     activityName = ri.activityInfo.name,
                     label = ri.loadLabel(pm).toString(),
                     icon = icon,
+                    firstInstallTime = installed.getOrPut(pkg) {
+                        runCatching { pm.getPackageInfo(pkg, 0).firstInstallTime }.getOrDefault(0L)
+                    },
                 )
             }
             .toList()
diff --git a/launcher/src/main/java/com/rizoma/orbita/data/UsageStore.kt b/launcher/src/main/java/com/rizoma/orbita/data/UsageStore.kt
index 190a892..0c30323 100644
--- a/launcher/src/main/java/com/rizoma/orbita/data/UsageStore.kt
+++ b/launcher/src/main/java/com/rizoma/orbita/data/UsageStore.kt
@@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore
 import androidx.datastore.preferences.core.Preferences
 import androidx.datastore.preferences.core.edit
 import androidx.datastore.preferences.core.intPreferencesKey
+import androidx.datastore.preferences.core.longPreferencesKey
 import androidx.datastore.preferences.core.stringSetPreferencesKey
 import androidx.datastore.preferences.preferencesDataStore
 import kotlinx.coroutines.flow.Flow
@@ -12,6 +13,15 @@ import kotlinx.coroutines.flow.map
 
 private val Context.orbitaStore: DataStore<Preferences> by preferencesDataStore(name = "orbita_usage")
 
+/** Toda la memoria orbital de una sola lectura del store. */
+data class UsageSnapshot(
+    val counts: Map<String, Int>,
+    val pinned: Set<String>,
+    val hidden: Set<String>,
+    val penalties: Map<String, Int>,
+    val hiddenAt: Map<String, Long>,
+)
+
 /**
  * Memoria orbital: cuántas veces se ha lanzado cada nodo (su calor) y cuáles están fijados.
  * El calor define a qué órbita cae el nodo; lo fijado nunca se desprende del centro.
@@ -20,6 +30,30 @@ class UsageStore(context: Context) {
 
     private val store = context.applicationContext.orbitaStore
 
+    /** Una foto completa de la memoria orbital (todo deriva de la misma lectura). */
+    fun snapshot(): Flow<UsageSnapshot> = store.data.map { prefs ->
+        val counts = HashMap<String, Int>()
+        val penalties = HashMap<String, Int>()
+        val hiddenAt = HashMap<String, Long>()
+        prefs.asMap().forEach { (key, value) ->
+            when {
+                key.name.startsWith(COUNT_PREFIX) && value is Int ->
+                    counts[key.name.removePrefix(COUNT_PREFIX)] = value
+                key.name.startsWith(PENALTY_PREFIX) && value is Int ->
+                    penalties[key.name.removePrefix(PENALTY_PREFIX)] = value
+                key.name.startsWith(HIDDENAT_PREFIX) && value is Long ->
+                    hiddenAt[key.name.removePrefix(HIDDENAT_PREFIX)] = value
+            }
+        }
+        UsageSnapshot(
+            counts = counts,
+            pinned = prefs[PINNED] ?: emptySet(),
+            hidden = prefs[HIDDEN] ?: emptySet(),
+            penalties = penalties,
+            hiddenAt = hiddenAt,
+        )
+    }
+
     fun counts(): Flow<Map<String, Int>> = store.data.map { prefs ->
         buildMap {
             prefs.asMap().forEach { (key, value) ->
@@ -61,15 +95,21 @@ class UsageStore(context: Context) {
         }
     }
 
-    /** Retira/devuelve un nodo al átomo. Al ocultarlo también lo suelta del centro. */
-    suspend fun setHidden(nodeKey: String, hidden: Boolean) {
+    /**
+     * Expulsa/devuelve un nodo del átomo. Al expulsarlo lo suelta del centro y
+     * sella el instante ([nowMs]) para que se vaya calmando hacia el destierro.
+     */
+    suspend fun setHidden(nodeKey: String, hidden: Boolean, nowMs: Long) {
         store.edit { prefs ->
             val current = prefs[HIDDEN]?.toMutableSet() ?: mutableSetOf()
+            val atKey = longPreferencesKey(HIDDENAT_PREFIX + nodeKey)
             if (hidden) {
                 current.add(nodeKey)
                 prefs[PINNED]?.let { prefs[PINNED] = it - nodeKey }
+                prefs[atKey] = nowMs
             } else {
                 current.remove(nodeKey)
+                prefs.remove(atKey)
             }
             prefs[HIDDEN] = current
         }
@@ -88,6 +128,7 @@ class UsageStore(context: Context) {
     private companion object {
         const val COUNT_PREFIX = "c:"
         const val PENALTY_PREFIX = "p:"
+        const val HIDDENAT_PREFIX = "h:"
         const val PUSH_BUFFER = 8
         val PINNED = stringSetPreferencesKey("pinned")
         val HIDDEN = stringSetPreferencesKey("hidden")
diff --git a/launcher/src/main/java/com/rizoma/orbita/model/AppNode.kt b/launcher/src/main/java/com/rizoma/orbita/model/AppNode.kt
index 583b94e..67a266a 100644
--- a/launcher/src/main/java/com/rizoma/orbita/model/AppNode.kt
+++ b/launcher/src/main/java/com/rizoma/orbita/model/AppNode.kt
@@ -5,6 +5,9 @@ import androidx.compose.ui.graphics.ImageBitmap
 /**
  * Un nodo del rizoma: una app lanzable con su calor de uso.
  * [score] es el peso orbital — más alto = órbita más interna.
+ * [firstInstallTime] (epoch ms) detecta apps recién instaladas ("nuevas").
+ * [hiddenAt] (epoch ms) marca cuándo se expulsó del átomo, para que las
+ * expulsadas se vayan calmando con el tiempo hasta quedar desterradas.
  */
 data class AppNode(
     val packageName: String,
@@ -14,6 +17,8 @@ data class AppNode(
     val score: Float = 0f,
     val pinned: Boolean = false,
     val hidden: Boolean = false,
+    val firstInstallTime: Long = 0L,
+    val hiddenAt: Long = 0L,
 ) {
     val key: String get() = "$packageName/$activityName"
 }
diff --git a/launcher/src/main/java/com/rizoma/orbita/model/Buckets.kt b/launcher/src/main/java/com/rizoma/orbita/model/Buckets.kt
new file mode 100644
index 0000000..74e78fc
--- /dev/null
+++ b/launcher/src/main/java/com/rizoma/orbita/model/Buckets.kt
@@ -0,0 +1,20 @@
+package com.rizoma.orbita.model
+
+/**
+ * El rizoma repartido en sus cinco zonas espaciales (de izquierda a derecha):
+ * el clima vive aparte; aquí van las cuatro que dependen de las apps.
+ *
+ *  - [atom]: el centro del átomo (apps establecidas, no expulsadas ni nuevas).
+ *  - [nuevas]: recién instaladas, rotando caóticamente "esperando entrar".
+ *  - [expulsadas]: retiradas hace poco; se alborotan y se van calmando.
+ *  - [desterradas]: expulsadas hace tiempo; ya quietas.
+ */
+data class Buckets(
+    val atom: List<AppNode>,
+    val nuevas: List<AppNode>,
+    val expulsadas: List<ExpelledNode>,
+    val desterradas: List<AppNode>,
+)
+
+/** Una app expulsada con su [turbulence] (1 = recién echada, 0 = calmada). */
+data class ExpelledNode(val node: AppNode, val turbulence: Float)
diff --git a/launcher/src/main/java/com/rizoma/orbita/ui/OrbitApp.kt b/launcher/src/main/java/com/rizoma/orbita/ui/OrbitApp.kt
index edf3bdf..a9da2d4 100644
--- a/launcher/src/main/java/com/rizoma/orbita/ui/OrbitApp.kt
+++ b/launcher/src/main/java/com/rizoma/orbita/ui/OrbitApp.kt
@@ -5,44 +5,61 @@ import android.content.pm.PackageManager
 import androidx.activity.compose.BackHandler
 import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animate
+import androidx.compose.animation.core.spring
 import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.Image
 import androidx.compose.foundation.background
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.draggable
+import androidx.compose.foundation.gestures.rememberDraggableState
 import androidx.compose.foundation.interaction.MutableInteractionSource
 import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
 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.lazy.LazyColumn
 import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.shape.CircleShape
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardOptions
 import androidx.compose.material3.Surface
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.setValue
-import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
 import androidx.compose.ui.focus.FocusRequester
 import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.graphicsLayer
 import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.res.stringResource
-import androidx.core.content.ContextCompat
 import androidx.compose.ui.text.TextStyle
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.text.input.ImeAction
@@ -50,8 +67,8 @@ import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
 import androidx.compose.ui.window.Dialog
+import androidx.core.content.ContextCompat
 import androidx.lifecycle.viewmodel.compose.viewModel
-import androidx.compose.runtime.collectAsState
 import com.rizoma.orbita.R
 import com.rizoma.orbita.model.AppNode
 import com.rizoma.orbita.ui.theme.NeonCian
@@ -60,49 +77,131 @@ import com.rizoma.orbita.ui.theme.NeonVerde
 import com.rizoma.orbita.ui.theme.Superficie
 import com.rizoma.orbita.ui.theme.Tenue
 import com.rizoma.orbita.ui.theme.Vacio
+import kotlin.math.abs
+import kotlin.math.roundToInt
+import kotlinx.coroutines.launch
+
+// El rizoma como cinta de cinco páginas (0..4). El centro del átomo es la 2.
+//  0 clima · 1 nuevas+pedazo · 2 centro del átomo · 3 pedazo+expulsadas · 4 desterradas
+private const val CENTER = 2f
+private const val FLING = 0.09f   // cuánto pesa la velocidad del gesto al decidir página
 
 @Composable
 fun OrbitApp(vm: OrbitaViewModel = viewModel()) {
-    val nodes by vm.nodes.collectAsState()
+    val buckets by vm.buckets.collectAsState()
+    val allNodes by vm.allNodes.collectAsState()
     val sky by vm.sky.collectAsState()
+    val weather by vm.weather.collectAsState()
+
     var searchOpen by remember { mutableStateOf(false) }
     var menuNode by remember { mutableStateOf<AppNode?>(null) }
 
-    // El cielo del fondo se tiñe con el clima local: pedimos ubicación aproximada
-    // una vez y, con permiso, refrescamos el clima.
     val context = LocalContext.current
+    val nimboInstalled = remember {
+        context.packageManager.getLaunchIntentForPackage("com.rizoma.nimbo") != null
+    }
+
+    // Posición continua entre páginas. Solo se lee de forma diferida (graphicsLayer,
+    // derivedStateOf, gestos): el cuerpo no se recompone en cada fotograma del arrastre.
+    var pos by remember { mutableFloatStateOf(CENTER) }
+    val scope = rememberCoroutineScope()
+
+    // Qué zonas existen en RAM: cada una se compone solo cerca de su página.
+    val showClima by remember { derivedStateOf { pos < 1.5f } }
+    val showNuevas by remember { derivedStateOf { pos > 0.3f && pos < 2.4f } }
+    val showExpulsadas by remember { derivedStateOf { pos > 1.6f && pos < 3.7f } }
+    val showDesterradas by remember { derivedStateOf { pos > 3.3f } }
+    val centered by remember { derivedStateOf { abs(pos - CENTER) < 0.02f } }
+
+    // Clima perezoso: solo se pide (permiso + red) al acercarse a su página.
     val permLauncher = rememberLauncherForActivityResult(
         ActivityResultContracts.RequestPermission()
     ) { granted -> if (granted) vm.refreshWeather() }
-    LaunchedEffect(Unit) {
-        val granted = ContextCompat.checkSelfPermission(
-            context, Manifest.permission.ACCESS_COARSE_LOCATION
-        ) == PackageManager.PERMISSION_GRANTED
-        if (granted) vm.refreshWeather()
-        else permLauncher.launch(Manifest.permission.ACCESS_COARSE_LOCATION)
+    var askedWeather by remember { mutableStateOf(false) }
+    LaunchedEffect(showClima) {
+        if (showClima && !askedWeather) {
+            askedWeather = true
+            val granted = ContextCompat.checkSelfPermission(
+                context, Manifest.permission.ACCESS_COARSE_LOCATION,
+            ) == PackageManager.PERMISSION_GRANTED
+            if (granted) vm.ensureWeather()
+            else permLauncher.launch(Manifest.permission.ACCESS_COARSE_LOCATION)
+        }
     }
 
-    // Como launcher: "atrás" cierra la búsqueda o no hace nada (nunca sale al sistema).
-    BackHandler(enabled = true) {
+    // Como launcher: "atrás" cierra la búsqueda, o vuelve al centro del átomo.
+    BackHandler(enabled = searchOpen || !centered) {
         if (searchOpen) searchOpen = false
+        else scope.launch { animate(pos, CENTER) { v, _ -> pos = v } }
     }
 
-    // Lo que orbita: el átomo sin los nodos retirados. Los ocultos siguen siendo
-    // alcanzables desde la búsqueda, para poder devolverlos.
-    val orbitNodes = remember(nodes) { nodes.filterNot { it.hidden } }
+    BoxWithConstraints(Modifier.fillMaxSize().background(Vacio)) {
+        val wPx = with(LocalDensity.current) { maxWidth.toPx() }
+        val drag = rememberDraggableState { delta -> pos = (pos - delta / wPx).coerceIn(0f, 4f) }
+
+        Box(
+            Modifier
+                .fillMaxSize()
+                .draggable(
+                    state = drag,
+                    orientation = Orientation.Horizontal,
+                    onDragStopped = { velocity ->
+                        val target = (pos - velocity / wPx * FLING).roundToInt().coerceIn(0, 4).toFloat()
+                        animate(
+                            initialValue = pos,
+                            targetValue = target,
+                            animationSpec = spring(dampingRatio = 0.85f, stiffness = Spring.StiffnessMediumLow),
+                        ) { v, _ -> pos = v }
+                    },
+                )
+        ) {
+            // El cielo es el telón continuo: no se desplaza con las páginas.
+            DynamicBackground(palette = sky)
+
+            // El átomo: UNA sola pieza que se desliza tras las páginas (de ahí la
+            // sensación de continuidad). En las páginas 1 y 3 asoma su borde.
+            Box(Modifier.fillMaxSize().graphicsLayer { translationX = (CENTER - pos) * wPx }) {
+                OrbitLayout(
+                    nodes = buckets.atom,
+                    onLaunch = vm::launch,
+                    onLongPress = { menuNode = it },
+                    onCenterTap = { searchOpen = true },
+                )
+            }
+
+            if (showClima) {
+                Box(Modifier.fillMaxSize().graphicsLayer { translationX = (0f - pos) * wPx }) {
+                    ClimaPage(weather, nimboInstalled) { vm.launchPackage("com.rizoma.nimbo") }
+                }
+            }
+            if (showNuevas) {
+                Box(Modifier.fillMaxSize().graphicsLayer { translationX = (1f - pos) * wPx }) {
+                    NuevasZone(buckets.nuevas, vm::launch) { menuNode = it }
+                }
+            }
+            if (showExpulsadas) {
+                Box(Modifier.fillMaxSize().graphicsLayer { translationX = (3f - pos) * wPx }) {
+                    ExpulsadasZone(buckets.expulsadas, vm::launch) { menuNode = it }
+                }
+            }
+            if (showDesterradas) {
+                Box(Modifier.fillMaxSize().graphicsLayer { translationX = (4f - pos) * wPx }) {
+                    DesterradasZone(buckets.desterradas, vm::launch) { menuNode = it }
+                }
+            }
 
-    Box(Modifier.fillMaxSize().background(Vacio)) {
-        DynamicBackground(palette = sky)
-        OrbitLayout(
-            nodes = orbitNodes,
-            onLaunch = vm::launch,
-            onLongPress = { menuNode = it },
-            onCenterTap = { searchOpen = true },
-        )
+            PageDots(
+                progress = { pos },
+                modifier = Modifier
+                    .align(Alignment.BottomCenter)
+                    .windowInsetsPadding(WindowInsets.systemBars)
+                    .padding(bottom = 22.dp),
+            )
+        }
 
         if (searchOpen) {
             SearchOverlay(
-                nodes = nodes,
+                nodes = allNodes,
                 onLaunch = {
                     vm.launch(it)
                     searchOpen = false
@@ -165,7 +264,7 @@ private fun SearchOverlay(
                 singleLine = true,
                 textStyle = TextStyle(color = NeonVerde, fontSize = 26.sp, fontWeight = FontWeight.Light),
                 cursorBrush = SolidColor(NeonRosa),
-                keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(imeAction = ImeAction.Search),
+                keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
                 modifier = Modifier.fillMaxWidth().focusRequester(focus),
                 decorationBox = { inner ->
                     if (query.isEmpty()) {
@@ -185,7 +284,7 @@ private fun SearchOverlay(
 
             LazyColumn(Modifier.fillMaxSize()) {
                 items(filtered, key = { it.key }) { node ->
-                    // Los ocultos se ven atenuados; un toque largo abre su menú (para devolverlos).
+                    // Los expulsados se ven atenuados; un toque largo abre su menú (para devolverlos).
                     val dim = if (node.hidden) 0.4f else 1f
                     Row(
                         Modifier
@@ -206,7 +305,7 @@ private fun SearchOverlay(
                         Spacer(Modifier.width(16.dp))
                         Text(
                             text = node.label,
-                            color = androidx.compose.ui.graphics.Color.White.copy(alpha = dim),
+                            color = Color.White.copy(alpha = dim),
                             fontSize = 16.sp,
                             maxLines = 1,
                             overflow = TextOverflow.Ellipsis,
@@ -244,14 +343,14 @@ private fun NodeMenu(
             Column(Modifier.width(240.dp).padding(vertical = 10.dp)) {
                 Text(
                     text = node.label,
-                    color = androidx.compose.ui.graphics.Color.White,
+                    color = Color.White,
                     fontSize = 15.sp,
                     fontWeight = FontWeight.Medium,
                     maxLines = 1,
                     overflow = TextOverflow.Ellipsis,
                     modifier = Modifier.padding(horizontal = 20.dp, vertical = 10.dp),
                 )
-                // Un nodo retirado solo se puede devolver, consultar o desinstalar:
+                // Un nodo expulsado solo se puede devolver, consultar o desinstalar:
                 // fijar/alejar no aplican mientras está fuera del átomo.
                 if (!node.hidden) {
                     MenuRow(
@@ -274,7 +373,7 @@ private fun NodeMenu(
 }
 
 @Composable
-private fun MenuRow(label: String, accent: androidx.compose.ui.graphics.Color, onClick: () -> Unit) {
+private fun MenuRow(label: String, accent: Color, onClick: () -> Unit) {
     Row(
         Modifier
             .fillMaxWidth()
@@ -284,6 +383,6 @@ private fun MenuRow(label: String, accent: androidx.compose.ui.graphics.Color, o
     ) {
         Box(Modifier.size(6.dp).clip(CircleShape).background(accent))
         Spacer(Modifier.width(14.dp))
-        Text(text = label, color = androidx.compose.ui.graphics.Color.White, fontSize = 15.sp)
+        Text(text = label, color = Color.White, fontSize = 15.sp)
     }
 }
diff --git a/launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt b/launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt
index 9ae1736..a371fc7 100644
--- a/launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt
+++ b/launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt
@@ -15,13 +15,17 @@ import com.rizoma.orbita.data.SkyRepository
 import com.rizoma.orbita.data.UsageStore
 import com.rizoma.orbita.data.Weather
 import com.rizoma.orbita.model.AppNode
+import com.rizoma.orbita.model.Buckets
+import com.rizoma.orbita.model.ExpelledNode
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.launch
 import java.util.Calendar
@@ -33,32 +37,55 @@ class OrbitaViewModel(app: Application) : AndroidViewModel(app) {
 
     private val rawApps = MutableStateFlow<List<AppNode>>(emptyList())
 
-    /** Nodos ya ponderados y ordenados: fijados primero, luego por calor, luego alfabético. */
-    val nodes: StateFlow<List<AppNode>> = combine(
-        rawApps,
-        usage.counts(),
-        usage.pinned(),
-        usage.penalties(),
-        usage.hidden(),
-    ) { apps, counts, pinned, penalties, hidden ->
+    /** Todas las apps ya ponderadas y ordenadas (fijados, calor, alfabético). Para la búsqueda. */
+    val allNodes: StateFlow<List<AppNode>> = combine(rawApps, usage.snapshot()) { apps, u ->
         apps.map { node ->
             node.copy(
-                score = (counts[node.key] ?: 0).toFloat(),
-                pinned = node.key in pinned,
-                hidden = node.key in hidden,
+                score = (u.counts[node.key] ?: 0).toFloat(),
+                pinned = node.key in u.pinned,
+                hidden = node.key in u.hidden,
+                hiddenAt = u.hiddenAt[node.key] ?: 0L,
             )
         }.sortedWith(
-            // El orden define la órbita: fijados al centro, luego por calor efectivo
-            // (calor menos penalización por "alejar"), luego alfabético.
             compareByDescending<AppNode> { it.pinned }
-                .thenByDescending { it.score - (penalties[it.key] ?: 0).toFloat() }
+                .thenByDescending { it.score - (u.penalties[it.key] ?: 0).toFloat() }
                 .thenBy { it.label.lowercase() }
         )
     }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
 
-    // --- Cielo del fondo: hora del día + clima local. ---
+    /** Las apps repartidas en las cuatro zonas (átomo, nuevas, expulsadas, desterradas). */
+    val buckets: StateFlow<Buckets> = allNodes
+        .map { classify(it) }
+        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), Buckets(emptyList(), emptyList(), emptyList(), emptyList()))
+
+    private fun classify(nodes: List<AppNode>): Buckets {
+        val now = System.currentTimeMillis()
+        val nuevas = nodes.filter {
+            !it.hidden && it.score == 0f &&
+                it.firstInstallTime > 0L && now - it.firstInstallTime < NEW_WINDOW
+        }.sortedByDescending { it.firstInstallTime }
+        val nuevasKeys = nuevas.mapTo(HashSet()) { it.key }
+
+        val atom = nodes.filter { !it.hidden && it.key !in nuevasKeys }
+
+        val expulsadas = ArrayList<ExpelledNode>()
+        val desterradas = ArrayList<AppNode>()
+        for (n in nodes.filter { it.hidden }) {
+            val age = now - n.hiddenAt
+            if (n.hiddenAt > 0L && age < CALM_WINDOW) {
+                expulsadas.add(ExpelledNode(n, (1f - age.toFloat() / CALM_WINDOW).coerceIn(0f, 1f)))
+            } else {
+                desterradas.add(n)
+            }
+        }
+        return Buckets(atom, nuevas, expulsadas, desterradas.sortedBy { it.label.lowercase() })
+    }
+
+    // --- Cielo del fondo: hora del día + clima local (perezoso). ---
 
-    private val weather = MutableStateFlow<Weather?>(null)
+    private val _weather = MutableStateFlow<Weather?>(null)
+    /** Clima actual (null hasta que se visita la página de clima). */
+    val weather: StateFlow<Weather?> = _weather.asStateFlow()
 
     /** Marca la hora actual (con fracción) y se reemite cada minuto. */
     private val clock = flow {
@@ -68,7 +95,7 @@ class OrbitaViewModel(app: Application) : AndroidViewModel(app) {
         }
     }
 
-    val sky: StateFlow<SkyPalette> = combine(clock, weather) { hour, w ->
+    val sky: StateFlow<SkyPalette> = combine(clock, _weather) { hour, w ->
         skyPaletteFor(hour, w)
     }.stateIn(
         viewModelScope,
@@ -76,10 +103,18 @@ class OrbitaViewModel(app: Application) : AndroidViewModel(app) {
         skyPaletteFor(currentHourFraction(), null),
     )
 
-    /** Refresca el clima local (best-effort). Llamar tras conceder ubicación. */
+    /**
+     * Refresca el clima local (best-effort, una sola vez salvo que se fuerce).
+     * Se llama al acercarse a la página de clima: mientras no se abra, no se carga.
+     */
+    fun ensureWeather() {
+        if (_weather.value != null) return
+        refreshWeather()
+    }
+
     fun refreshWeather() {
         viewModelScope.launch(Dispatchers.IO) {
-            SkyRepository(getApplication()).fetch()?.let { weather.value = it }
+            SkyRepository(getApplication()).fetch()?.let { _weather.value = it }
         }
     }
 
@@ -118,13 +153,20 @@ class OrbitaViewModel(app: Application) : AndroidViewModel(app) {
         viewModelScope.launch { usage.record(node.key) }
     }
 
+    /** Abre una app por nombre de paquete (p.ej. Nimbo desde la página de clima). */
+    fun launchPackage(pkg: String): Boolean {
+        val intent = getApplication<Application>().packageManager.getLaunchIntentForPackage(pkg)
+            ?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) ?: return false
+        return runCatching { getApplication<Application>().startActivity(intent); true }.getOrDefault(false)
+    }
+
     fun togglePin(node: AppNode) {
         viewModelScope.launch { usage.setPinned(node.key, !node.pinned) }
     }
 
-    /** Retira el nodo del átomo (o lo devuelve). No desinstala: la app sigue ahí. */
+    /** Expulsa el nodo del átomo (o lo devuelve). No desinstala: la app sigue ahí. */
     fun toggleHidden(node: AppNode) {
-        viewModelScope.launch { usage.setHidden(node.key, !node.hidden) }
+        viewModelScope.launch { usage.setHidden(node.key, !node.hidden, System.currentTimeMillis()) }
     }
 
     /** Aleja el nodo a órbitas externas (usa su calor actual como referencia). */
@@ -150,4 +192,9 @@ class OrbitaViewModel(app: Application) : AndroidViewModel(app) {
     override fun onCleared() {
         runCatching { getApplication<Application>().unregisterReceiver(pkgReceiver) }
     }
+
+    private companion object {
+        const val NEW_WINDOW = 3L * 24 * 3600 * 1000   // "nueva" durante 3 días
+        const val CALM_WINDOW = 7L * 24 * 3600 * 1000  // se calma/destierra en 7 días
+    }
 }
diff --git a/launcher/src/main/java/com/rizoma/orbita/ui/Pages.kt b/launcher/src/main/java/com/rizoma/orbita/ui/Pages.kt
new file mode 100644
index 0000000..eed715a
--- /dev/null
+++ b/launcher/src/main/java/com/rizoma/orbita/ui/Pages.kt
@@ -0,0 +1,308 @@
+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.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<AppNode>, 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<ExpelledNode>, 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<AppNode>, 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) {
+    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
+}