tawasuyu

sergio/humanoid

HTML 57Markdown 2TOML 10 Sutichaykuna

Órbita: quitar apps del átomo (sin desinstalar) + fondo según hora y clima

- Nuevo: ocultar un nodo lo retira del átomo pero la app sigue instalada;
  se restaura desde la búsqueda (long-press → "devolver al átomo"). Los
  ocultos aparecen atenuados y marcados en la búsqueda. Menú distingue ya
  "quitar del átomo" de "desinstalar".
- Fondo dinámico: paleta del cielo derivada de la hora del día (anclas
  noche/alba/mediodía/ocaso interpoladas) y modulada por el clima local
  (Open-Meteo, sin clave ni dependencias; ubicación aproximada del sistema).
  Transiciones suaves entre cielos.

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

608145b4b469bb0f8b58179bb8338053430cbdb6 · ruwaq sergio · (4 killa ñawpaq) · tayta f809af7f4 · kay commitpi sach'ata qhaway

Semántico sayk'uy

Mana ima sutichaypas t'ikrakurqanchu: qillqalla, yachachiylla utaq willaylla t'ikrakurqan.

10 qillqakuna · +362 −25

diff --git a/launcher/src/main/AndroidManifest.xml b/launcher/src/main/AndroidManifest.xml
index 8df0925..9188ac8 100644
--- a/launcher/src/main/AndroidManifest.xml
+++ b/launcher/src/main/AndroidManifest.xml
@@ -11,6 +11,10 @@
 
     <uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
 
+    <!-- Clima local para el fondo: ubicación aproximada + red (Open-Meteo). -->
+    <uses-permission android:name="android.permission.INTERNET" />
+    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
+
     <application
         android:label="@string/app_name"
         android:icon="@mipmap/ic_launcher"
diff --git a/launcher/src/main/java/com/rizoma/orbita/data/SkyRepository.kt b/launcher/src/main/java/com/rizoma/orbita/data/SkyRepository.kt
new file mode 100644
index 0000000..bab801e
--- /dev/null
+++ b/launcher/src/main/java/com/rizoma/orbita/data/SkyRepository.kt
@@ -0,0 +1,73 @@
+package com.rizoma.orbita.data
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import android.location.Location
+import android.location.LocationManager
+import androidx.core.content.ContextCompat
+import org.json.JSONObject
+import java.net.HttpURLConnection
+import java.net.URL
+
+/**
+ * Clima local sin dependencias ni clave: ubicación aproximada del sistema +
+ * Open-Meteo sobre HttpURLConnection. Todo es best-effort: si falta permiso,
+ * ubicación o red, devuelve null y el fondo se queda solo con la hora.
+ */
+class SkyRepository(context: Context) {
+
+    private val app = context.applicationContext
+
+    /** Llamar fuera del hilo principal: hace E/S de red. */
+    fun fetch(): Weather? {
+        val loc = lastLocation() ?: return null
+        return query(loc.latitude, loc.longitude)
+    }
+
+    private fun lastLocation(): Location? {
+        val granted = ContextCompat.checkSelfPermission(
+            app, Manifest.permission.ACCESS_COARSE_LOCATION,
+        ) == PackageManager.PERMISSION_GRANTED
+        if (!granted) return null
+        val lm = app.getSystemService(Context.LOCATION_SERVICE) as? LocationManager ?: return null
+        // La más reciente entre los proveedores accesibles. GPS puede lanzar
+        // SecurityException con permiso solo aproximado: lo absorbemos.
+        return listOf(
+            LocationManager.NETWORK_PROVIDER,
+            LocationManager.GPS_PROVIDER,
+            LocationManager.PASSIVE_PROVIDER,
+        ).mapNotNull { p ->
+            runCatching {
+                if (lm.isProviderEnabled(p)) lm.getLastKnownLocation(p) else null
+            }.getOrNull()
+        }.maxByOrNull { it.time }
+    }
+
+    private fun query(lat: Double, lon: Double): Weather? {
+        val url = URL(
+            "https://api.open-meteo.com/v1/forecast" +
+                "?latitude=$lat&longitude=$lon" +
+                "&current=temperature_2m,weather_code,is_day",
+        )
+        return runCatching {
+            val conn = (url.openConnection() as HttpURLConnection).apply {
+                connectTimeout = 8_000
+                readTimeout = 8_000
+                requestMethod = "GET"
+            }
+            try {
+                if (conn.responseCode != HttpURLConnection.HTTP_OK) return null
+                val body = conn.inputStream.bufferedReader().use { it.readText() }
+                val cur = JSONObject(body).getJSONObject("current")
+                Weather(
+                    code = cur.getInt("weather_code"),
+                    temperatureC = cur.getDouble("temperature_2m").toFloat(),
+                    isDay = cur.optInt("is_day", 1) == 1,
+                )
+            } finally {
+                conn.disconnect()
+            }
+        }.getOrNull()
+    }
+}
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 56c49ad..190a892 100644
--- a/launcher/src/main/java/com/rizoma/orbita/data/UsageStore.kt
+++ b/launcher/src/main/java/com/rizoma/orbita/data/UsageStore.kt
@@ -32,6 +32,9 @@ class UsageStore(context: Context) {
 
     fun pinned(): Flow<Set<String>> = store.data.map { it[PINNED] ?: emptySet() }
 
+    /** Nodos retirados del átomo: siguen instalados, pero no orbitan ni se cuentan. */
+    fun hidden(): Flow<Set<String>> = store.data.map { it[HIDDEN] ?: emptySet() }
+
     /** Penalización manual de órbita: el usuario "aleja" un nodo que se acercó demasiado. */
     fun penalties(): Flow<Map<String, Int>> = store.data.map { prefs ->
         buildMap {
@@ -58,6 +61,20 @@ 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) {
+        store.edit { prefs ->
+            val current = prefs[HIDDEN]?.toMutableSet() ?: mutableSetOf()
+            if (hidden) {
+                current.add(nodeKey)
+                prefs[PINNED]?.let { prefs[PINNED] = it - nodeKey }
+            } else {
+                current.remove(nodeKey)
+            }
+            prefs[HIDDEN] = current
+        }
+    }
+
     /**
      * Aleja un nodo: fija una penalización por encima de su calor actual, de modo que
      * cae a órbitas externas. Volverá a acercarse solo si se vuelve a usar bastante.
@@ -73,5 +90,6 @@ class UsageStore(context: Context) {
         const val PENALTY_PREFIX = "p:"
         const val PUSH_BUFFER = 8
         val PINNED = stringSetPreferencesKey("pinned")
+        val HIDDEN = stringSetPreferencesKey("hidden")
     }
 }
diff --git a/launcher/src/main/java/com/rizoma/orbita/data/Weather.kt b/launcher/src/main/java/com/rizoma/orbita/data/Weather.kt
new file mode 100644
index 0000000..e172ddb
--- /dev/null
+++ b/launcher/src/main/java/com/rizoma/orbita/data/Weather.kt
@@ -0,0 +1,26 @@
+package com.rizoma.orbita.data
+
+/** Familias de cielo en las que agrupamos los códigos WMO de Open-Meteo. */
+enum class Sky { CLEAR, CLOUDY, OVERCAST, FOG, RAIN, SNOW, STORM }
+
+/**
+ * Estado del clima local en este instante. [code] es el código WMO
+ * (https://open-meteo.com/en/docs) del que derivamos la familia de [sky].
+ */
+data class Weather(
+    val code: Int,
+    val temperatureC: Float,
+    val isDay: Boolean,
+) {
+    val sky: Sky
+        get() = when (code) {
+            0, 1 -> Sky.CLEAR
+            2 -> Sky.CLOUDY
+            3 -> Sky.OVERCAST
+            45, 48 -> Sky.FOG
+            51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82 -> Sky.RAIN
+            71, 73, 75, 77, 85, 86 -> Sky.SNOW
+            95, 96, 99 -> Sky.STORM
+            else -> Sky.CLOUDY
+        }
+}
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 b75b461..583b94e 100644
--- a/launcher/src/main/java/com/rizoma/orbita/model/AppNode.kt
+++ b/launcher/src/main/java/com/rizoma/orbita/model/AppNode.kt
@@ -13,6 +13,7 @@ data class AppNode(
     val icon: ImageBitmap,
     val score: Float = 0f,
     val pinned: Boolean = false,
+    val hidden: Boolean = false,
 ) {
     val key: String get() = "$packageName/$activityName"
 }
diff --git a/launcher/src/main/java/com/rizoma/orbita/ui/DynamicBackground.kt b/launcher/src/main/java/com/rizoma/orbita/ui/DynamicBackground.kt
index f6390e3..443d501 100644
--- a/launcher/src/main/java/com/rizoma/orbita/ui/DynamicBackground.kt
+++ b/launcher/src/main/java/com/rizoma/orbita/ui/DynamicBackground.kt
@@ -1,8 +1,10 @@
 package com.rizoma.orbita.ui
 
+import androidx.compose.animation.animateColorAsState
 import androidx.compose.animation.core.LinearEasing
 import androidx.compose.animation.core.RepeatMode
 import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.animateFloatAsState
 import androidx.compose.animation.core.infiniteRepeatable
 import androidx.compose.animation.core.rememberInfiniteTransition
 import androidx.compose.animation.core.tween
@@ -15,16 +17,11 @@ import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.graphics.BlendMode
 import androidx.compose.ui.graphics.Brush
 import androidx.compose.ui.graphics.Color
-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.Vacio
 import kotlin.math.PI
 import kotlin.math.cos
 import kotlin.math.sin
 
 private data class Blob(
-    val color: Color,
     val periodMs: Int,
     val ax: Float, val ay: Float,   // amplitud relativa del vaivén
     val ox: Float, val oy: Float,   // origen relativo
@@ -33,17 +30,20 @@ private data class Blob(
 )
 
 private val blobs = listOf(
-    Blob(NeonVerde, 49_000, 0.34f, 0.30f, 0.42f, 0.40f, 1.30f, 0f),
-    Blob(NeonRosa, 67_000, 0.38f, 0.26f, 0.58f, 0.62f, 0.80f, 2.1f),
-    Blob(NeonCian, 83_000, 0.30f, 0.34f, 0.50f, 0.30f, 1.70f, 4.0f),
+    Blob(49_000, 0.34f, 0.30f, 0.42f, 0.40f, 1.30f, 0f),
+    Blob(67_000, 0.38f, 0.26f, 0.58f, 0.62f, 0.80f, 2.1f),
+    Blob(83_000, 0.30f, 0.34f, 0.50f, 0.30f, 1.70f, 4.0f),
 )
 
+private const val FADE_MS = 4_000
+
 /**
- * Fondo dinámico congruente con el rizoma: manchas de luz neón que derivan en
- * trayectorias de Lissajous y laten suavemente, mezcladas de forma aditiva sobre el vacío.
+ * Fondo dinámico congruente con el rizoma: manchas de luz que derivan en trayectorias
+ * de Lissajous y laten suavemente, mezcladas de forma aditiva sobre el vacío. Los colores
+ * vienen de [palette] (hora del día + clima) y transicionan despacio entre cielos.
  */
 @Composable
-fun DynamicBackground(modifier: Modifier = Modifier) {
+fun DynamicBackground(modifier: Modifier = Modifier, palette: SkyPalette = DefaultSky) {
     val transition = rememberInfiniteTransition(label = "fondo")
     val tau = (2 * PI).toFloat()
     // Una fase por mancha (llamadas directas: animateFloat es @Composable, no cabe en un map/función local).
@@ -58,20 +58,30 @@ fun DynamicBackground(modifier: Modifier = Modifier) {
     )
     val phases = floatArrayOf(p0, p1, p2)
 
+    // El cambio de cielo (hora/clima) se difumina; el movimiento sigue siendo continuo.
+    val fade = tween<Color>(FADE_MS, easing = LinearEasing)
+    val base by animateColorAsState(palette.base, fade, label = "base")
+    val cA by animateColorAsState(palette.a, fade, label = "a")
+    val cB by animateColorAsState(palette.b, fade, label = "b")
+    val cC by animateColorAsState(palette.c, fade, label = "c")
+    val intensity by animateFloatAsState(palette.intensity, tween(FADE_MS, easing = LinearEasing), label = "intensity")
+    val colors = listOf(cA, cB, cC)
+
     Canvas(modifier.fillMaxSize()) {
-        drawRect(Vacio)
+        drawRect(base)
         val w = size.width
         val h = size.height
-        val base = maxOf(w, h)
+        val span = maxOf(w, h)
+        val alpha = (0.16f * intensity).coerceIn(0f, 1f)
         blobs.forEachIndexed { i, blob ->
             val t = phases[i] + blob.phase
             val cx = w * (blob.ox + blob.ax * cos(t))
             val cy = h * (blob.oy + blob.ay * sin(t * blob.freqY))
             val pulse = 0.82f + 0.18f * sin(t * 1.7f)
-            val radius = base * 0.62f * pulse
+            val radius = span * 0.62f * pulse
             drawCircle(
                 brush = Brush.radialGradient(
-                    colors = listOf(blob.color.copy(alpha = 0.16f), Color.Transparent),
+                    colors = listOf(colors[i].copy(alpha = alpha), Color.Transparent),
                     center = Offset(cx, cy),
                     radius = radius,
                 ),
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 8797fc4..edf3bdf 100644
--- a/launcher/src/main/java/com/rizoma/orbita/ui/OrbitApp.kt
+++ b/launcher/src/main/java/com/rizoma/orbita/ui/OrbitApp.kt
@@ -1,9 +1,15 @@
 package com.rizoma.orbita.ui
 
+import android.Manifest
+import android.content.pm.PackageManager
 import androidx.activity.compose.BackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+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.interaction.MutableInteractionSource
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
@@ -34,7 +40,9 @@ import androidx.compose.ui.draw.clip
 import androidx.compose.ui.focus.FocusRequester
 import androidx.compose.ui.focus.focusRequester
 import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.platform.LocalContext
 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
@@ -56,18 +64,37 @@ import com.rizoma.orbita.ui.theme.Vacio
 @Composable
 fun OrbitApp(vm: OrbitaViewModel = viewModel()) {
     val nodes by vm.nodes.collectAsState()
+    val sky by vm.sky.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 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)
+    }
+
     // Como launcher: "atrás" cierra la búsqueda o no hace nada (nunca sale al sistema).
     BackHandler(enabled = true) {
         if (searchOpen) searchOpen = false
     }
 
+    // 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 } }
+
     Box(Modifier.fillMaxSize().background(Vacio)) {
-        DynamicBackground()
+        DynamicBackground(palette = sky)
         OrbitLayout(
-            nodes = nodes,
+            nodes = orbitNodes,
             onLaunch = vm::launch,
             onLongPress = { menuNode = it },
             onCenterTap = { searchOpen = true },
@@ -80,6 +107,7 @@ fun OrbitApp(vm: OrbitaViewModel = viewModel()) {
                     vm.launch(it)
                     searchOpen = false
                 },
+                onLongPress = { menuNode = it },
                 onClose = { searchOpen = false },
             )
         }
@@ -89,6 +117,7 @@ fun OrbitApp(vm: OrbitaViewModel = viewModel()) {
                 node = node,
                 onPin = { vm.togglePin(node); menuNode = null },
                 onPush = { vm.pushAway(node); menuNode = null },
+                onHide = { vm.toggleHidden(node); menuNode = null },
                 onInfo = { vm.openInfo(node); menuNode = null },
                 onUninstall = { vm.uninstall(node); menuNode = null },
                 onDismiss = { menuNode = null },
@@ -97,10 +126,12 @@ fun OrbitApp(vm: OrbitaViewModel = viewModel()) {
     }
 }
 
+@OptIn(ExperimentalFoundationApi::class)
 @Composable
 private fun SearchOverlay(
     nodes: List<AppNode>,
     onLaunch: (AppNode) -> Unit,
+    onLongPress: (AppNode) -> Unit,
     onClose: () -> Unit,
 ) {
     var query by remember { mutableStateOf("") }
@@ -154,26 +185,40 @@ 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).
+                    val dim = if (node.hidden) 0.4f else 1f
                     Row(
                         Modifier
                             .fillMaxWidth()
-                            .clickable { onLaunch(node) }
+                            .combinedClickable(
+                                onClick = { onLaunch(node) },
+                                onLongClick = { onLongPress(node) },
+                            )
                             .padding(vertical = 8.dp),
                         verticalAlignment = Alignment.CenterVertically,
                     ) {
                         Image(
                             bitmap = node.icon,
                             contentDescription = node.label,
+                            alpha = dim,
                             modifier = Modifier.size(40.dp).clip(CircleShape),
                         )
                         Spacer(Modifier.width(16.dp))
                         Text(
                             text = node.label,
-                            color = androidx.compose.ui.graphics.Color.White,
+                            color = androidx.compose.ui.graphics.Color.White.copy(alpha = dim),
                             fontSize = 16.sp,
                             maxLines = 1,
                             overflow = TextOverflow.Ellipsis,
                         )
+                        if (node.hidden) {
+                            Spacer(Modifier.width(10.dp))
+                            Text(
+                                text = stringResource(R.string.tag_hidden),
+                                color = NeonCian.copy(alpha = 0.7f),
+                                fontSize = 11.sp,
+                            )
+                        }
                     }
                 }
             }
@@ -186,6 +231,7 @@ private fun NodeMenu(
     node: AppNode,
     onPin: () -> Unit,
     onPush: () -> Unit,
+    onHide: () -> Unit,
     onInfo: () -> Unit,
     onUninstall: () -> Unit,
     onDismiss: () -> Unit,
@@ -205,12 +251,21 @@ private fun NodeMenu(
                     overflow = TextOverflow.Ellipsis,
                     modifier = Modifier.padding(horizontal = 20.dp, vertical = 10.dp),
                 )
+                // Un nodo retirado solo se puede devolver, consultar o desinstalar:
+                // fijar/alejar no aplican mientras está fuera del átomo.
+                if (!node.hidden) {
+                    MenuRow(
+                        label = stringResource(if (node.pinned) R.string.menu_unpin else R.string.menu_pin),
+                        accent = NeonRosa,
+                        onClick = onPin,
+                    )
+                    MenuRow(label = stringResource(R.string.menu_push), accent = NeonVerde, onClick = onPush)
+                }
                 MenuRow(
-                    label = stringResource(if (node.pinned) R.string.menu_unpin else R.string.menu_pin),
-                    accent = NeonRosa,
-                    onClick = onPin,
+                    label = stringResource(if (node.hidden) R.string.menu_unhide else R.string.menu_hide),
+                    accent = NeonCian,
+                    onClick = onHide,
                 )
-                MenuRow(label = stringResource(R.string.menu_push), accent = NeonVerde, onClick = onPush)
                 MenuRow(label = stringResource(R.string.menu_info), accent = NeonCian, onClick = onInfo)
                 MenuRow(label = stringResource(R.string.menu_uninstall), accent = NeonRosa, onClick = onUninstall)
             }
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 3730a6e..9ae1736 100644
--- a/launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt
+++ b/launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt
@@ -11,15 +11,20 @@ import android.provider.Settings
 import androidx.lifecycle.AndroidViewModel
 import androidx.lifecycle.viewModelScope
 import com.rizoma.orbita.data.AppRepository
+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 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.combine
+import kotlinx.coroutines.flow.flow
 import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.launch
+import java.util.Calendar
 
 class OrbitaViewModel(app: Application) : AndroidViewModel(app) {
 
@@ -34,11 +39,13 @@ class OrbitaViewModel(app: Application) : AndroidViewModel(app) {
         usage.counts(),
         usage.pinned(),
         usage.penalties(),
-    ) { apps, counts, pinned, penalties ->
+        usage.hidden(),
+    ) { apps, counts, pinned, penalties, hidden ->
         apps.map { node ->
             node.copy(
                 score = (counts[node.key] ?: 0).toFloat(),
                 pinned = node.key in pinned,
+                hidden = node.key in hidden,
             )
         }.sortedWith(
             // El orden define la órbita: fijados al centro, luego por calor efectivo
@@ -49,6 +56,38 @@ class OrbitaViewModel(app: Application) : AndroidViewModel(app) {
         )
     }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
 
+    // --- Cielo del fondo: hora del día + clima local. ---
+
+    private val weather = MutableStateFlow<Weather?>(null)
+
+    /** Marca la hora actual (con fracción) y se reemite cada minuto. */
+    private val clock = flow {
+        while (true) {
+            emit(currentHourFraction())
+            delay(60_000L)
+        }
+    }
+
+    val sky: StateFlow<SkyPalette> = combine(clock, weather) { hour, w ->
+        skyPaletteFor(hour, w)
+    }.stateIn(
+        viewModelScope,
+        SharingStarted.WhileSubscribed(5_000),
+        skyPaletteFor(currentHourFraction(), null),
+    )
+
+    /** Refresca el clima local (best-effort). Llamar tras conceder ubicación. */
+    fun refreshWeather() {
+        viewModelScope.launch(Dispatchers.IO) {
+            SkyRepository(getApplication()).fetch()?.let { weather.value = it }
+        }
+    }
+
+    private fun currentHourFraction(): Float {
+        val c = Calendar.getInstance()
+        return c.get(Calendar.HOUR_OF_DAY) + c.get(Calendar.MINUTE) / 60f
+    }
+
     private val pkgReceiver = object : BroadcastReceiver() {
         override fun onReceive(context: Context?, intent: Intent?) = refresh()
     }
@@ -83,6 +122,11 @@ class OrbitaViewModel(app: Application) : AndroidViewModel(app) {
         viewModelScope.launch { usage.setPinned(node.key, !node.pinned) }
     }
 
+    /** Retira 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) }
+    }
+
     /** Aleja el nodo a órbitas externas (usa su calor actual como referencia). */
     fun pushAway(node: AppNode) {
         viewModelScope.launch { usage.pushAway(node.key, node.score.toInt()) }
diff --git a/launcher/src/main/java/com/rizoma/orbita/ui/Sky.kt b/launcher/src/main/java/com/rizoma/orbita/ui/Sky.kt
new file mode 100644
index 0000000..9bbca0b
--- /dev/null
+++ b/launcher/src/main/java/com/rizoma/orbita/ui/Sky.kt
@@ -0,0 +1,103 @@
+package com.rizoma.orbita.ui
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.lerp
+import com.rizoma.orbita.data.Sky
+import com.rizoma.orbita.data.Weather
+import com.rizoma.orbita.ui.theme.NeonCian
+import com.rizoma.orbita.ui.theme.NeonRosa
+import com.rizoma.orbita.ui.theme.NeonVerde
+
+/**
+ * El "cielo" del fondo: un color base (el vacío teñido) y las tres manchas de luz,
+ * más una [intensity] global que el clima atenúa o aviva.
+ */
+data class SkyPalette(
+    val base: Color,
+    val a: Color,
+    val b: Color,
+    val c: Color,
+    val intensity: Float,
+)
+
+/** Mediodía neón del rizoma: el fondo por defecto sin hora ni clima resueltos. */
+val DefaultSky = SkyPalette(Color(0xFF050507), NeonVerde, NeonRosa, NeonCian, 1f)
+
+/**
+ * Paleta del cielo para una hora (0..24, con fracción) y, si lo hay, el clima local.
+ * La hora marca el tono base; el clima lo modula (despejado aviva, nublado apaga,
+ * lluvia enfría hacia azul, nieve aclara, tormenta tira a violeta).
+ */
+fun skyPaletteFor(hour: Float, weather: Weather?): SkyPalette {
+    val base = basePalette(hour)
+    val w = weather ?: return base
+    return when (w.sky) {
+        Sky.CLEAR -> base.copy(intensity = base.intensity * 1.08f)
+        Sky.CLOUDY -> base.desaturate(0.25f, 0.85f)
+        Sky.OVERCAST -> base.desaturate(0.45f, 0.65f)
+        Sky.FOG -> base.desaturate(0.60f, 0.55f)
+            .copy(base = lerp(base.base, Color(0xFF20242B), 0.40f))
+        Sky.RAIN -> base.shift(LLUVIA, 0.40f, 0.70f)
+        Sky.SNOW -> base.shift(NIEVE, 0.45f, 0.85f)
+            .copy(base = lerp(base.base, Color(0xFF10161F), 0.50f))
+        Sky.STORM -> base.shift(TORMENTA, 0.45f, 0.70f)
+    }
+}
+
+private class Anchor(val hour: Float, val palette: SkyPalette)
+
+// Anclas a lo largo del día; entre ellas se interpola (con vuelta a medianoche).
+private val anchors = listOf(
+    Anchor(2f, SkyPalette(Color(0xFF05060E), NeonCian, Color(0xFF6A4CFF), Color(0xFF1E3A8A), 0.70f)),
+    Anchor(6f, SkyPalette(Color(0xFF0E0810), Color(0xFFFF7E5F), NeonRosa, Color(0xFFFFC36B), 0.90f)),
+    Anchor(12f, SkyPalette(Color(0xFF050507), NeonVerde, NeonRosa, NeonCian, 1.00f)),
+    Anchor(18f, SkyPalette(Color(0xFF0C060A), Color(0xFFFF5E3A), Color(0xFFB14CFF), NeonRosa, 0.95f)),
+    Anchor(22f, SkyPalette(Color(0xFF07060E), Color(0xFF6A4CFF), Color(0xFF1E3A8A), NeonCian, 0.75f)),
+)
+
+private val GRIS = Color(0xFF8A8F99)
+private val LLUVIA = Color(0xFF2A6FB0)
+private val NIEVE = Color(0xFFCFE8FF)
+private val TORMENTA = Color(0xFF7A3CFF)
+
+private fun basePalette(hour: Float): SkyPalette {
+    val h = ((hour % 24f) + 24f) % 24f
+    for (i in anchors.indices) {
+        val cur = anchors[i]
+        val nxt = anchors[(i + 1) % anchors.size]
+        val start = cur.hour
+        var end = nxt.hour
+        var hh = h
+        if (end <= start) {           // tramo que cruza medianoche (22 → 2)
+            end += 24f
+            if (hh < start) hh += 24f
+        }
+        if (hh in start..end) {
+            val t = ((hh - start) / (end - start)).coerceIn(0f, 1f)
+            return lerpPalette(cur.palette, nxt.palette, t)
+        }
+    }
+    return anchors[0].palette
+}
+
+private fun lerpPalette(x: SkyPalette, y: SkyPalette, t: Float) = SkyPalette(
+    base = lerp(x.base, y.base, t),
+    a = lerp(x.a, y.a, t),
+    b = lerp(x.b, y.b, t),
+    c = lerp(x.c, y.c, t),
+    intensity = x.intensity + (y.intensity - x.intensity) * t,
+)
+
+private fun SkyPalette.desaturate(amount: Float, intensityMul: Float) = copy(
+    a = lerp(a, GRIS, amount),
+    b = lerp(b, GRIS, amount),
+    c = lerp(c, GRIS, amount),
+    intensity = intensity * intensityMul,
+)
+
+private fun SkyPalette.shift(toward: Color, amount: Float, intensityMul: Float) = copy(
+    a = lerp(a, toward, amount),
+    b = lerp(b, toward, amount * 0.7f),
+    c = lerp(c, toward, amount),
+    intensity = intensity * intensityMul,
+)
diff --git a/launcher/src/main/res/values/strings.xml b/launcher/src/main/res/values/strings.xml
index 9cb4aa4..eda3875 100644
--- a/launcher/src/main/res/values/strings.xml
+++ b/launcher/src/main/res/values/strings.xml
@@ -5,6 +5,9 @@
     <string name="menu_pin">fijar</string>
     <string name="menu_unpin">soltar</string>
     <string name="menu_push">alejar</string>
+    <string name="menu_hide">quitar del átomo</string>
+    <string name="menu_unhide">devolver al átomo</string>
     <string name="menu_info">info</string>
-    <string name="menu_uninstall">quitar</string>
+    <string name="menu_uninstall">desinstalar</string>
+    <string name="tag_hidden">fuera del átomo</string>
 </resources>