Ó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>
Delta semántico
Ninguna definición cambió: el cambio es de texto, documentación o datos.
10 archivos · +362 −25
- launcher/src/main/AndroidManifest.xml +4 −0
- launcher/src/main/java/com/rizoma/orbita/data/SkyRepository.kt +73 −0
- launcher/src/main/java/com/rizoma/orbita/data/UsageStore.kt +18 −0
- launcher/src/main/java/com/rizoma/orbita/data/Weather.kt +26 −0
- launcher/src/main/java/com/rizoma/orbita/model/AppNode.kt +1 −0
- launcher/src/main/java/com/rizoma/orbita/ui/DynamicBackground.kt +25 −15
- launcher/src/main/java/com/rizoma/orbita/ui/OrbitApp.kt +63 −8
- launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt +45 −1
- launcher/src/main/java/com/rizoma/orbita/ui/Sky.kt +103 −0
- launcher/src/main/res/values/strings.xml +4 −1
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" +
+ "¤t=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>