Nimbo: nuevo módulo :nimbo — clima como pintura ambiente (Open-Meteo + cielo vivo)
App de clima donde el cielo vivo ES la interfaz: reutiliza el motor de fondo de Órbita (manchas Lissajous con mezcla aditiva cuya paleta deriva de hora local + clima) a pantalla completa, y encima pinta un pronóstico real. - Datos Open-Meteo sin clave ni deps (HttpURLConnection): actual + 24h + 7 días, con timezone=auto. Ubicación aproximada del sistema (last-known) y geocoding inverso (Android Geocoder) para el nombre del lugar. - Hora local del lugar calculada al vuelo desde utc_offset → el cielo es correcto aunque el dato venga de caché. - Caché del último pronóstico en DataStore (JSON crudo): abre al instante/offline y refresca en segundo plano. - Glifos de clima monoline dibujados a mano en Canvas (sol/luna, nube, niebla, lluvia, nieve, tormenta), sin emojis. - UI: temperatura grande, métricas (sensación/viento/humedad), franja por horas con prob. de precipitación, lista de 7 días con barras frío→cálido. - Permiso de ubicación pedido en runtime; estados de carga/permiso/error. - Estética del rizoma: neón sobre vacío, español minúscula. minSdk 26, sin clave. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Semántico sayk'uy
Mana ima sutichaypas t'ikrakurqanchu: qillqalla, yachachiylla utaq willaylla t'ikrakurqan.
19 qillqakuna · +1171 −0
- nimbo/build.gradle.kts +50 −0
- nimbo/src/main/AndroidManifest.xml +26 −0
- nimbo/src/main/java/com/rizoma/nimbo/MainActivity.kt +20 −0
- nimbo/src/main/java/com/rizoma/nimbo/NimboViewModel.kt +54 −0
- nimbo/src/main/java/com/rizoma/nimbo/data/Cache.kt +40 −0
- nimbo/src/main/java/com/rizoma/nimbo/data/Forecast.kt +45 −0
- nimbo/src/main/java/com/rizoma/nimbo/data/SkyRepository.kt +165 −0
- nimbo/src/main/java/com/rizoma/nimbo/data/Weather.kt +51 −0
- nimbo/src/main/java/com/rizoma/nimbo/ui/DynamicBackground.kt +93 −0
- nimbo/src/main/java/com/rizoma/nimbo/ui/Glyphs.kt +127 −0
- nimbo/src/main/java/com/rizoma/nimbo/ui/NimboApp.kt +315 −0
- nimbo/src/main/java/com/rizoma/nimbo/ui/Sky.kt +103 −0
- nimbo/src/main/java/com/rizoma/nimbo/ui/theme/Theme.kt +36 −0
- nimbo/src/main/res/drawable/ic_launcher_fg.xml +23 −0
- nimbo/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +5 −0
- nimbo/src/main/res/values/colors.xml +4 −0
- nimbo/src/main/res/values/strings.xml +4 −0
- nimbo/src/main/res/values/themes.xml +9 −0
- settings.gradle.kts +1 −0
diff --git a/nimbo/build.gradle.kts b/nimbo/build.gradle.kts
new file mode 100644
index 0000000..2477edc
--- /dev/null
+++ b/nimbo/build.gradle.kts
@@ -0,0 +1,50 @@
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.compose)
+}
+
+android {
+ namespace = "com.rizoma.nimbo"
+ compileSdk = 36
+
+ defaultConfig {
+ applicationId = "com.rizoma.nimbo"
+ minSdk = 26
+ targetSdk = 36
+ versionCode = 1
+ versionName = "0.1.0"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+ buildFeatures {
+ compose = true
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
+ implementation(libs.androidx.activity.compose)
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.compose.ui)
+ implementation(libs.androidx.compose.ui.graphics)
+ implementation(libs.androidx.compose.ui.util)
+ implementation(libs.androidx.compose.foundation)
+ implementation(libs.androidx.compose.animation)
+ implementation(libs.androidx.compose.material3)
+ implementation(libs.androidx.datastore.preferences)
+ implementation(libs.kotlinx.coroutines.android)
+}
diff --git a/nimbo/src/main/AndroidManifest.xml b/nimbo/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..c5c2508
--- /dev/null
+++ b/nimbo/src/main/AndroidManifest.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <!-- Clima local: ubicación aproximada + red (Open-Meteo, sin clave). -->
+ <uses-permission android:name="android.permission.INTERNET" />
+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+ <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
+
+ <application
+ android:label="@string/app_name"
+ android:icon="@mipmap/ic_launcher"
+ android:theme="@style/Theme.Nimbo"
+ android:supportsRtl="true">
+
+ <activity
+ android:name=".MainActivity"
+ android:exported="true"
+ android:launchMode="singleTask"
+ android:configChanges="orientation|screenSize|keyboard|keyboardHidden|uiMode">
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN" />
+ <category android:name="android.intent.category.LAUNCHER" />
+ </intent-filter>
+ </activity>
+ </application>
+</manifest>
diff --git a/nimbo/src/main/java/com/rizoma/nimbo/MainActivity.kt b/nimbo/src/main/java/com/rizoma/nimbo/MainActivity.kt
new file mode 100644
index 0000000..f91f293
--- /dev/null
+++ b/nimbo/src/main/java/com/rizoma/nimbo/MainActivity.kt
@@ -0,0 +1,20 @@
+package com.rizoma.nimbo
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.core.view.WindowCompat
+import com.rizoma.nimbo.ui.NimboApp
+import com.rizoma.nimbo.ui.theme.NimboTheme
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+ setContent {
+ NimboTheme {
+ NimboApp()
+ }
+ }
+ }
+}
diff --git a/nimbo/src/main/java/com/rizoma/nimbo/NimboViewModel.kt b/nimbo/src/main/java/com/rizoma/nimbo/NimboViewModel.kt
new file mode 100644
index 0000000..9e81b70
--- /dev/null
+++ b/nimbo/src/main/java/com/rizoma/nimbo/NimboViewModel.kt
@@ -0,0 +1,54 @@
+package com.rizoma.nimbo
+
+import android.app.Application
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import com.rizoma.nimbo.data.Forecast
+import com.rizoma.nimbo.data.SkyRepository
+import kotlinx.coroutines.launch
+
+enum class Status { Loading, NeedsPermission, Ready, Error }
+
+class NimboViewModel(app: Application) : AndroidViewModel(app) {
+ private val repo = SkyRepository(app)
+
+ var forecast by mutableStateOf<Forecast?>(null); private set
+ var status by mutableStateOf(Status.Loading); private set
+ var refreshing by mutableStateOf(false); private set
+
+ init {
+ viewModelScope.launch {
+ repo.cached()?.let { forecast = it; status = Status.Ready }
+ when {
+ repo.hasLocation() -> refresh()
+ forecast == null -> status = Status.NeedsPermission
+ }
+ }
+ }
+
+ /** ¿Hay que lanzar la petición de permiso al arrancar? */
+ fun shouldAskPermission(): Boolean = !repo.hasLocation()
+
+ fun onPermission(granted: Boolean) {
+ if (granted) refresh()
+ else if (forecast == null) status = Status.NeedsPermission
+ }
+
+ fun refresh() {
+ if (refreshing) return
+ refreshing = true
+ viewModelScope.launch {
+ val fc = repo.fetch()
+ if (fc != null) {
+ forecast = fc
+ status = Status.Ready
+ } else if (forecast == null) {
+ status = if (repo.hasLocation()) Status.Error else Status.NeedsPermission
+ }
+ refreshing = false
+ }
+ }
+}
diff --git a/nimbo/src/main/java/com/rizoma/nimbo/data/Cache.kt b/nimbo/src/main/java/com/rizoma/nimbo/data/Cache.kt
new file mode 100644
index 0000000..5cff37d
--- /dev/null
+++ b/nimbo/src/main/java/com/rizoma/nimbo/data/Cache.kt
@@ -0,0 +1,40 @@
+package com.rizoma.nimbo.data
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.longPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import kotlinx.coroutines.flow.first
+
+private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "nimbo")
+
+/** Snapshot crudo del último pronóstico, para abrir al instante (y offline). */
+data class Snapshot(val json: String, val place: String, val updatedMs: Long)
+
+/** Caché del último pronóstico en DataStore (JSON crudo + lugar + marca de tiempo). */
+class Cache(context: Context) {
+ private val store = context.applicationContext.dataStore
+
+ suspend fun read(): Snapshot? {
+ val p = store.data.first()
+ val json = p[KEY_JSON] ?: return null
+ return Snapshot(json, p[KEY_PLACE].orEmpty(), p[KEY_TIME] ?: 0L)
+ }
+
+ suspend fun write(json: String, place: String, updatedMs: Long) {
+ store.edit { p ->
+ p[KEY_JSON] = json
+ p[KEY_PLACE] = place
+ p[KEY_TIME] = updatedMs
+ }
+ }
+
+ private companion object {
+ val KEY_JSON = stringPreferencesKey("forecast_json")
+ val KEY_PLACE = stringPreferencesKey("place")
+ val KEY_TIME = longPreferencesKey("updated_ms")
+ }
+}
diff --git a/nimbo/src/main/java/com/rizoma/nimbo/data/Forecast.kt b/nimbo/src/main/java/com/rizoma/nimbo/data/Forecast.kt
new file mode 100644
index 0000000..08d80ff
--- /dev/null
+++ b/nimbo/src/main/java/com/rizoma/nimbo/data/Forecast.kt
@@ -0,0 +1,45 @@
+package com.rizoma.nimbo.data
+
+/** Lo que pinta la app: lugar, estado actual, próximas horas y próximos días. */
+data class Forecast(
+ val place: String,
+ val utcOffsetSeconds: Int,
+ val current: Current,
+ val hourly: List<HourSlot>,
+ val daily: List<DaySlot>,
+ val updatedEpochMs: Long,
+)
+
+data class Current(
+ val code: Int,
+ val temperatureC: Float,
+ val apparentC: Float,
+ val isDay: Boolean,
+ val humidity: Int,
+ val windKmh: Float,
+ val precipMm: Float,
+)
+
+data class HourSlot(
+ val label: String, // "ahora", "18", "19"…
+ val temperatureC: Float,
+ val code: Int,
+ val precipProb: Int, // % de probabilidad de precipitación
+)
+
+data class DaySlot(
+ val label: String, // "hoy", "lun", "mar"…
+ val code: Int,
+ val minC: Float,
+ val maxC: Float,
+)
+
+/**
+ * Hora local del lugar (0..24 con fracción), calculada desde el desfase UTC del
+ * pronóstico y el reloj del dispositivo. Se recalcula al vuelo para que el cielo
+ * sea correcto aunque el dato venga de la caché.
+ */
+fun localHourFrom(utcOffsetSeconds: Int): Float {
+ val secs = (((System.currentTimeMillis() / 1000) + utcOffsetSeconds) % 86_400 + 86_400) % 86_400
+ return secs / 3600f
+}
diff --git a/nimbo/src/main/java/com/rizoma/nimbo/data/SkyRepository.kt b/nimbo/src/main/java/com/rizoma/nimbo/data/SkyRepository.kt
new file mode 100644
index 0000000..c3f1778
--- /dev/null
+++ b/nimbo/src/main/java/com/rizoma/nimbo/data/SkyRepository.kt
@@ -0,0 +1,165 @@
+package com.rizoma.nimbo.data
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import android.location.Geocoder
+import android.location.Location
+import android.location.LocationManager
+import androidx.core.content.ContextCompat
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.json.JSONObject
+import java.net.HttpURLConnection
+import java.net.URL
+import java.time.LocalDate
+import java.util.Locale
+
+/**
+ * Pronóstico local sin dependencias ni clave: ubicación aproximada del sistema
+ * + Open-Meteo sobre HttpURLConnection, con geocoding inverso para el nombre
+ * del lugar y caché para abrir al instante. Todo best-effort: si falta permiso,
+ * ubicación o red, se cae a la caché o devuelve null.
+ */
+class SkyRepository(context: Context) {
+
+ private val app = context.applicationContext
+ private val cache = Cache(app)
+
+ fun hasLocation(): Boolean = ContextCompat.checkSelfPermission(
+ app, Manifest.permission.ACCESS_COARSE_LOCATION,
+ ) == PackageManager.PERMISSION_GRANTED
+
+ /** Pronóstico cacheado (si lo hay), re-parseado para pintar de inmediato. */
+ suspend fun cached(): Forecast? {
+ val s = cache.read() ?: return null
+ return parse(s.json, s.place, s.updatedMs)
+ }
+
+ /** Descarga un pronóstico fresco y lo cachea. Hace E/S: corre en IO. */
+ suspend fun fetch(): Forecast? = withContext(Dispatchers.IO) {
+ val loc = lastLocation() ?: return@withContext null
+ val place = reverseGeocode(loc.latitude, loc.longitude)
+ val body = httpGet(buildUrl(loc.latitude, loc.longitude)) ?: return@withContext null
+ val now = System.currentTimeMillis()
+ val fc = parse(body, place, now) ?: return@withContext null
+ cache.write(body, place, now)
+ fc
+ }
+
+ // --- ubicación -----------------------------------------------------------
+
+ private fun lastLocation(): Location? {
+ if (!hasLocation()) return null
+ val lm = app.getSystemService(Context.LOCATION_SERVICE) as? LocationManager ?: return null
+ 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 }
+ }
+
+ @Suppress("DEPRECATION")
+ private fun reverseGeocode(lat: Double, lon: Double): String = runCatching {
+ val a = Geocoder(app, Locale("es")).getFromLocation(lat, lon, 1)?.firstOrNull()
+ a?.locality ?: a?.subAdminArea ?: a?.adminArea ?: a?.countryName
+ }.getOrNull() ?: "tu zona"
+
+ // --- red -----------------------------------------------------------------
+
+ private fun buildUrl(lat: Double, lon: Double): String =
+ "https://api.open-meteo.com/v1/forecast?latitude=$lat&longitude=$lon" +
+ "¤t=temperature_2m,apparent_temperature,relative_humidity_2m," +
+ "weather_code,is_day,wind_speed_10m,precipitation" +
+ "&hourly=temperature_2m,weather_code,precipitation_probability" +
+ "&daily=weather_code,temperature_2m_max,temperature_2m_min" +
+ "&timezone=auto&forecast_days=7"
+
+ private fun httpGet(url: String): String? = runCatching {
+ val conn = (URL(url).openConnection() as HttpURLConnection).apply {
+ connectTimeout = 8_000
+ readTimeout = 8_000
+ requestMethod = "GET"
+ }
+ try {
+ if (conn.responseCode != HttpURLConnection.HTTP_OK) null
+ else conn.inputStream.bufferedReader().use { it.readText() }
+ } finally {
+ conn.disconnect()
+ }
+ }.getOrNull()
+
+ // --- parseo --------------------------------------------------------------
+
+ private fun parse(body: String, place: String, updatedMs: Long): Forecast? = runCatching {
+ val root = JSONObject(body)
+ val offset = root.optInt("utc_offset_seconds", 0)
+
+ val cur = root.getJSONObject("current")
+ val temp = cur.getDouble("temperature_2m").toFloat()
+ val current = Current(
+ code = cur.getInt("weather_code"),
+ temperatureC = temp,
+ apparentC = cur.optDouble("apparent_temperature", temp.toDouble()).toFloat(),
+ isDay = cur.optInt("is_day", 1) == 1,
+ humidity = cur.optInt("relative_humidity_2m", 0),
+ windKmh = cur.optDouble("wind_speed_10m", 0.0).toFloat(),
+ precipMm = cur.optDouble("precipitation", 0.0).toFloat(),
+ )
+ // Hora actual truncada a la hora en punto, para incluir el tramo en curso.
+ val nowHour = cur.getString("time").substring(0, 13) + ":00"
+
+ val h = root.getJSONObject("hourly")
+ val ht = h.getJSONArray("time")
+ val htemp = h.getJSONArray("temperature_2m")
+ val hcode = h.getJSONArray("weather_code")
+ val hprob = h.optJSONArray("precipitation_probability")
+ var start = 0
+ while (start < ht.length() && ht.getString(start) < nowHour) start++
+ val hourly = ArrayList<HourSlot>()
+ var i = start
+ while (i < ht.length() && hourly.size < 24) {
+ val hh = ht.getString(i).substring(11, 13).toInt()
+ hourly.add(
+ HourSlot(
+ label = if (i == start) "ahora" else hh.toString(),
+ temperatureC = htemp.getDouble(i).toFloat(),
+ code = hcode.getInt(i),
+ precipProb = hprob?.optInt(i, 0) ?: 0,
+ ),
+ )
+ i++
+ }
+
+ val d = root.getJSONObject("daily")
+ val dt = d.getJSONArray("time")
+ val dcode = d.getJSONArray("weather_code")
+ val dmax = d.getJSONArray("temperature_2m_max")
+ val dmin = d.getJSONArray("temperature_2m_min")
+ val daily = ArrayList<DaySlot>()
+ for (k in 0 until dt.length()) {
+ daily.add(
+ DaySlot(
+ label = if (k == 0) "hoy" else weekdayEs(dt.getString(k)),
+ code = dcode.getInt(k),
+ minC = dmin.getDouble(k).toFloat(),
+ maxC = dmax.getDouble(k).toFloat(),
+ ),
+ )
+ }
+
+ Forecast(place, offset, current, hourly, daily, updatedMs)
+ }.getOrNull()
+
+ private fun weekdayEs(date: String): String =
+ DIAS[LocalDate.parse(date).dayOfWeek.value - 1]
+
+ private companion object {
+ // DayOfWeek.value: lunes = 1 … domingo = 7
+ val DIAS = arrayOf("lun", "mar", "mié", "jue", "vie", "sáb", "dom")
+ }
+}
diff --git a/nimbo/src/main/java/com/rizoma/nimbo/data/Weather.kt b/nimbo/src/main/java/com/rizoma/nimbo/data/Weather.kt
new file mode 100644
index 0000000..95b4e16
--- /dev/null
+++ b/nimbo/src/main/java/com/rizoma/nimbo/data/Weather.kt
@@ -0,0 +1,51 @@
+package com.rizoma.nimbo.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 }
+
+/** Código WMO → familia de cielo (para el fondo y los glifos). */
+fun skyOf(code: Int): Sky = 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
+}
+
+/** Descripción en español, minúscula, del código WMO. */
+fun describir(code: Int): String = when (code) {
+ 0 -> "despejado"
+ 1 -> "casi despejado"
+ 2 -> "parcialmente nublado"
+ 3 -> "cubierto"
+ 45, 48 -> "niebla"
+ 51, 53, 55 -> "llovizna"
+ 56, 57 -> "llovizna helada"
+ 61, 63 -> "lluvia"
+ 65 -> "lluvia fuerte"
+ 66, 67 -> "lluvia helada"
+ 71, 73 -> "nieve"
+ 75 -> "nieve intensa"
+ 77 -> "aguanieve"
+ 80, 81 -> "chubascos"
+ 82 -> "chubascos fuertes"
+ 85, 86 -> "chubascos de nieve"
+ 95 -> "tormenta"
+ 96, 99 -> "tormenta con granizo"
+ else -> "—"
+}
+
+/**
+ * Estado del clima en un instante. [code] es el código WMO; de él derivamos
+ * la familia de [sky]. Lo usa el motor de fondo para teñir el cielo.
+ */
+data class Weather(
+ val code: Int,
+ val temperatureC: Float,
+ val isDay: Boolean,
+) {
+ val sky: Sky get() = skyOf(code)
+}
diff --git a/nimbo/src/main/java/com/rizoma/nimbo/ui/DynamicBackground.kt b/nimbo/src/main/java/com/rizoma/nimbo/ui/DynamicBackground.kt
new file mode 100644
index 0000000..d3b6b42
--- /dev/null
+++ b/nimbo/src/main/java/com/rizoma/nimbo/ui/DynamicBackground.kt
@@ -0,0 +1,93 @@
+package com.rizoma.nimbo.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
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+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 kotlin.math.PI
+import kotlin.math.cos
+import kotlin.math.sin
+
+private data class Blob(
+ val periodMs: Int,
+ val ax: Float, val ay: Float, // amplitud relativa del vaivén
+ val ox: Float, val oy: Float, // origen relativo
+ val freqY: Float, // relación de frecuencia vertical (Lissajous)
+ val phase: Float,
+)
+
+private val blobs = listOf(
+ 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: 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. En Nimbo es la interfaz entera: el clima como pintura ambiente.
+ */
+@Composable
+fun DynamicBackground(modifier: Modifier = Modifier, palette: SkyPalette = DefaultSky) {
+ val transition = rememberInfiniteTransition(label = "fondo")
+ val tau = (2 * PI).toFloat()
+ val p0 by transition.animateFloat(
+ 0f, tau, infiniteRepeatable(tween(blobs[0].periodMs, easing = LinearEasing), RepeatMode.Restart), label = "p0",
+ )
+ val p1 by transition.animateFloat(
+ 0f, tau, infiniteRepeatable(tween(blobs[1].periodMs, easing = LinearEasing), RepeatMode.Restart), label = "p1",
+ )
+ val p2 by transition.animateFloat(
+ 0f, tau, infiniteRepeatable(tween(blobs[2].periodMs, easing = LinearEasing), RepeatMode.Restart), label = "p2",
+ )
+ val phases = floatArrayOf(p0, p1, p2)
+
+ 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(base)
+ val w = size.width
+ val h = size.height
+ 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 = span * 0.62f * pulse
+ drawCircle(
+ brush = Brush.radialGradient(
+ colors = listOf(colors[i].copy(alpha = alpha), Color.Transparent),
+ center = Offset(cx, cy),
+ radius = radius,
+ ),
+ radius = radius,
+ center = Offset(cx, cy),
+ blendMode = BlendMode.Plus,
+ )
+ }
+ }
+}
diff --git a/nimbo/src/main/java/com/rizoma/nimbo/ui/Glyphs.kt b/nimbo/src/main/java/com/rizoma/nimbo/ui/Glyphs.kt
new file mode 100644
index 0000000..df35204
--- /dev/null
+++ b/nimbo/src/main/java/com/rizoma/nimbo/ui/Glyphs.kt
@@ -0,0 +1,127 @@
+package com.rizoma.nimbo.ui
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import com.rizoma.nimbo.data.Sky
+import com.rizoma.nimbo.data.skyOf
+import kotlin.math.PI
+import kotlin.math.cos
+import kotlin.math.sin
+
+/** Glifo de clima monoline/silueta, dibujado a mano (sin emojis). */
+@Composable
+fun WeatherGlyph(
+ code: Int,
+ isDay: Boolean,
+ modifier: Modifier = Modifier,
+ color: Color = Color.White,
+) {
+ Canvas(modifier) { drawGlyph(skyOf(code), isDay, color) }
+}
+
+private fun DrawScope.drawGlyph(sky: Sky, isDay: Boolean, color: Color) {
+ val u = minOf(size.width, size.height)
+ val cx = size.width / 2f
+ val cy = size.height / 2f
+ val stroke = u * 0.07f
+ val astro = color.copy(alpha = 0.95f)
+
+ fun cloud(ox: Float, oy: Float, w: Float, alpha: Float) {
+ val c = color.copy(alpha = alpha)
+ val r = w * 0.27f
+ drawRoundRect(
+ color = c,
+ topLeft = Offset(ox - w / 2f, oy - r * 0.35f),
+ size = Size(w, r * 1.35f),
+ cornerRadius = CornerRadius(r),
+ )
+ drawCircle(c, r * 0.92f, Offset(ox - w * 0.22f, oy))
+ drawCircle(c, r * 1.18f, Offset(ox + w * 0.02f, oy - r * 0.40f))
+ drawCircle(c, r * 0.86f, Offset(ox + w * 0.28f, oy + r * 0.02f))
+ }
+
+ fun sun(scx: Float, scy: Float, r: Float, rays: Boolean) {
+ drawCircle(astro, r, Offset(scx, scy))
+ if (rays) {
+ for (k in 0 until 8) {
+ val ang = (PI / 4 * k).toFloat()
+ drawLine(
+ astro,
+ Offset(scx + cos(ang) * r * 1.45f, scy + sin(ang) * r * 1.45f),
+ Offset(scx + cos(ang) * r * 1.95f, scy + sin(ang) * r * 1.95f),
+ strokeWidth = stroke,
+ cap = StrokeCap.Round,
+ )
+ }
+ }
+ }
+
+ when (sky) {
+ Sky.CLEAR -> {
+ sun(cx, cy, u * 0.20f, rays = isDay)
+ if (!isDay) {
+ // dos estrellas para la noche despejada
+ drawCircle(astro, u * 0.03f, Offset(cx + u * 0.30f, cy - u * 0.26f))
+ drawCircle(astro, u * 0.022f, Offset(cx - u * 0.28f, cy + u * 0.24f))
+ }
+ }
+ Sky.CLOUDY -> {
+ sun(cx - u * 0.16f, cy - u * 0.16f, u * 0.15f, rays = isDay)
+ cloud(cx + u * 0.06f, cy + u * 0.08f, u * 0.66f, 1f)
+ }
+ Sky.OVERCAST -> {
+ cloud(cx - u * 0.10f, cy - u * 0.06f, u * 0.56f, 0.55f)
+ cloud(cx + u * 0.06f, cy + u * 0.10f, u * 0.66f, 1f)
+ }
+ Sky.FOG -> {
+ cloud(cx, cy - u * 0.12f, u * 0.66f, 0.85f)
+ for (k in 0 until 3) {
+ val y = cy + u * (0.16f + k * 0.12f)
+ drawLine(
+ color.copy(alpha = 0.7f),
+ Offset(cx - u * 0.26f, y), Offset(cx + u * 0.26f, y),
+ strokeWidth = stroke * 0.8f, cap = StrokeCap.Round,
+ )
+ }
+ }
+ Sky.RAIN -> {
+ cloud(cx, cy - u * 0.14f, u * 0.66f, 1f)
+ for (k in 0 until 3) {
+ val x = cx + (k - 1) * u * 0.18f
+ drawLine(
+ color.copy(alpha = 0.85f),
+ Offset(x, cy + u * 0.16f), Offset(x - u * 0.06f, cy + u * 0.34f),
+ strokeWidth = stroke * 0.8f, cap = StrokeCap.Round,
+ )
+ }
+ }
+ Sky.SNOW -> {
+ cloud(cx, cy - u * 0.14f, u * 0.66f, 1f)
+ for (k in 0 until 3) {
+ val x = cx + (k - 1) * u * 0.18f
+ drawCircle(color.copy(alpha = 0.85f), u * 0.035f, Offset(x, cy + u * 0.26f))
+ }
+ }
+ Sky.STORM -> {
+ cloud(cx, cy - u * 0.16f, u * 0.66f, 1f)
+ val bolt = Path().apply {
+ moveTo(cx + u * 0.04f, cy + u * 0.04f)
+ lineTo(cx - u * 0.12f, cy + u * 0.22f)
+ lineTo(cx - u * 0.01f, cy + u * 0.22f)
+ lineTo(cx - u * 0.10f, cy + u * 0.42f)
+ lineTo(cx + u * 0.17f, cy + u * 0.16f)
+ lineTo(cx + u * 0.03f, cy + u * 0.16f)
+ close()
+ }
+ drawPath(bolt, com.rizoma.nimbo.ui.theme.NeonRosa)
+ }
+ }
+}
diff --git a/nimbo/src/main/java/com/rizoma/nimbo/ui/NimboApp.kt b/nimbo/src/main/java/com/rizoma/nimbo/ui/NimboApp.kt
new file mode 100644
index 0000000..d75d0f2
--- /dev/null
+++ b/nimbo/src/main/java/com/rizoma/nimbo/ui/NimboApp.kt
@@ -0,0 +1,315 @@
+package com.rizoma.nimbo.ui
+
+import android.Manifest
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+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.statusBars
+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.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+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.geometry.Offset
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.lerp
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.rizoma.nimbo.NimboViewModel
+import com.rizoma.nimbo.Status
+import com.rizoma.nimbo.data.DaySlot
+import com.rizoma.nimbo.data.Forecast
+import com.rizoma.nimbo.data.HourSlot
+import com.rizoma.nimbo.data.Weather
+import com.rizoma.nimbo.data.describir
+import com.rizoma.nimbo.data.localHourFrom
+import com.rizoma.nimbo.ui.theme.MuyTenue
+import com.rizoma.nimbo.ui.theme.NeonCian
+import com.rizoma.nimbo.ui.theme.Tenue
+import com.rizoma.nimbo.ui.theme.Vacio
+import kotlin.math.roundToInt
+
+@Composable
+fun NimboApp(vm: NimboViewModel = viewModel()) {
+ val launcher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission(),
+ ) { vm.onPermission(it) }
+
+ LaunchedEffect(Unit) {
+ if (vm.shouldAskPermission()) launcher.launch(Manifest.permission.ACCESS_COARSE_LOCATION)
+ }
+
+ val forecast = vm.forecast
+ val palette = forecast?.let {
+ skyPaletteFor(
+ localHourFrom(it.utcOffsetSeconds),
+ Weather(it.current.code, it.current.temperatureC, it.current.isDay),
+ )
+ } ?: DefaultSky
+
+ Box(Modifier.fillMaxSize().background(Vacio)) {
+ DynamicBackground(palette = palette)
+ // Velo para legibilidad del texto sobre el cielo en movimiento.
+ Box(
+ Modifier.fillMaxSize().background(
+ Brush.verticalGradient(
+ listOf(Color.Black.copy(alpha = 0.12f), Color.Transparent, Color.Black.copy(alpha = 0.45f)),
+ ),
+ ),
+ )
+
+ when {
+ forecast != null -> ForecastContent(forecast) { vm.refresh() }
+ vm.status == Status.NeedsPermission -> Centro(
+ "nimbo necesita tu ubicación aproximada para leer el cielo de tu zona",
+ "permitir",
+ ) { launcher.launch(Manifest.permission.ACCESS_COARSE_LOCATION) }
+ vm.status == Status.Error -> Centro(
+ "no pude alcanzar el cielo — revisa tu conexión",
+ "reintentar",
+ ) { vm.refresh() }
+ else -> Box(Modifier.fillMaxSize(), Alignment.Center) {
+ CircularProgressIndicator(color = NeonCian, strokeWidth = 2.dp)
+ }
+ }
+
+ if (vm.refreshing && forecast != null) {
+ CircularProgressIndicator(
+ color = NeonCian,
+ strokeWidth = 2.dp,
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .windowInsetsPadding(WindowInsets.statusBars)
+ .padding(top = 10.dp)
+ .size(18.dp),
+ )
+ }
+ }
+}
+
+@Composable
+private fun ForecastContent(f: Forecast, onRefresh: () -> Unit) {
+ Column(
+ Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .windowInsetsPadding(WindowInsets.systemBars)
+ .padding(horizontal = 24.dp),
+ ) {
+ Spacer(Modifier.height(28.dp))
+
+ // Lugar + actualizado (toca para refrescar).
+ Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).clickable(onClick = onRefresh)) {
+ Text(f.place, color = Color.White, fontSize = 26.sp, fontWeight = FontWeight.SemiBold)
+ Text("actualizado ${hace(f.updatedEpochMs)} · toca para refrescar", color = MuyTenue, fontSize = 12.sp)
+ }
+
+ Spacer(Modifier.height(24.dp))
+
+ // Glifo grande + temperatura.
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ WeatherGlyph(
+ f.current.code, f.current.isDay,
+ modifier = Modifier.size(76.dp), color = Color.White,
+ )
+ Spacer(Modifier.width(16.dp))
+ Text(
+ "${f.current.temperatureC.roundToInt()}°",
+ color = Color.White, fontSize = 84.sp, fontWeight = FontWeight.Light,
+ )
+ }
+ Text(describir(f.current.code), color = Tenue, fontSize = 20.sp)
+
+ Spacer(Modifier.height(18.dp))
+
+ // Métricas.
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(28.dp)) {
+ Metric("sensación", "${f.current.apparentC.roundToInt()}°")
+ Metric("viento", "${f.current.windKmh.roundToInt()} km/h")
+ Metric("humedad", "${f.current.humidity}%")
+ }
+
+ Spacer(Modifier.height(26.dp))
+ Bloque("por horas") { HourlyStrip(f.hourly) }
+
+ Spacer(Modifier.height(22.dp))
+ Bloque("7 días") { DailyList(f.daily) }
+
+ Spacer(Modifier.height(28.dp))
+ }
+}
+
+@Composable
+private fun Metric(label: String, value: String) {
+ Column {
+ Text(label, color = MuyTenue, fontSize = 11.sp, letterSpacing = 2.sp)
+ Text(value, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Medium)
+ }
+}
+
+@Composable
+private fun Bloque(titulo: String, content: @Composable () -> Unit) {
+ Surface(color = Color.White.copy(alpha = 0.06f), shape = RoundedCornerShape(20.dp)) {
+ Column(Modifier.fillMaxWidth().padding(vertical = 16.dp)) {
+ Text(
+ titulo,
+ color = MuyTenue, fontSize = 11.sp, letterSpacing = 3.sp,
+ modifier = Modifier.padding(start = 18.dp, bottom = 12.dp),
+ )
+ content()
+ }
+ }
+}
+
+@Composable
+private fun HourlyStrip(hours: List<HourSlot>) {
+ Row(
+ Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 14.dp),
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ hours.forEach { slot ->
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier.width(46.dp),
+ ) {
+ Text(slot.label, color = Tenue, fontSize = 12.sp, maxLines = 1)
+ Spacer(Modifier.height(8.dp))
+ WeatherGlyph(slot.code, isDay = true, modifier = Modifier.size(26.dp), color = Color.White)
+ Spacer(Modifier.height(8.dp))
+ Text("${slot.temperatureC.roundToInt()}°", color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Medium)
+ Text(
+ if (slot.precipProb >= 10) "${slot.precipProb}%" else " ",
+ color = NeonCian, fontSize = 10.sp,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun DailyList(days: List<DaySlot>) {
+ if (days.isEmpty()) return
+ val weekMin = days.minOf { it.minC }
+ val weekMax = days.maxOf { it.maxC }
+ val span = (weekMax - weekMin).coerceAtLeast(1f)
+
+ Column(Modifier.fillMaxWidth().padding(horizontal = 18.dp)) {
+ days.forEach { d ->
+ Row(
+ Modifier.fillMaxWidth().padding(vertical = 7.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(d.label, color = Tenue, fontSize = 15.sp, modifier = Modifier.width(40.dp))
+ WeatherGlyph(d.code, isDay = true, modifier = Modifier.size(22.dp), color = Color.White)
+ Spacer(Modifier.width(14.dp))
+ Text(
+ "${d.minC.roundToInt()}°",
+ color = MuyTenue, fontSize = 14.sp,
+ textAlign = TextAlign.End, modifier = Modifier.width(30.dp),
+ )
+ Spacer(Modifier.width(10.dp))
+ TempBar(
+ startFrac = (d.minC - weekMin) / span,
+ endFrac = (d.maxC - weekMin) / span,
+ minC = d.minC, maxC = d.maxC,
+ modifier = Modifier.weight(1f).height(6.dp),
+ )
+ Spacer(Modifier.width(10.dp))
+ Text(
+ "${d.maxC.roundToInt()}°",
+ color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Medium,
+ modifier = Modifier.width(30.dp),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun TempBar(startFrac: Float, endFrac: Float, minC: Float, maxC: Float, modifier: Modifier) {
+ androidx.compose.foundation.Canvas(modifier) {
+ val y = size.height / 2f
+ drawLine(
+ Color.White.copy(alpha = 0.12f),
+ Offset(0f, y), Offset(size.width, y),
+ strokeWidth = size.height, cap = StrokeCap.Round,
+ )
+ val x0 = size.width * startFrac.coerceIn(0f, 1f)
+ // Asegura ancho mínimo (un punto) para no degenerar el gradiente.
+ val x1 = (size.width * endFrac.coerceIn(0f, 1f)).coerceAtLeast(x0 + size.height)
+ drawLine(
+ brush = Brush.horizontalGradient(
+ listOf(colorForTemp(minC), colorForTemp(maxC)),
+ startX = x0, endX = x1,
+ ),
+ start = Offset(x0, y), end = Offset(x1, y),
+ strokeWidth = size.height, cap = StrokeCap.Round,
+ )
+ }
+}
+
+@Composable
+private fun Centro(mensaje: String, accion: String, onClick: () -> Unit) {
+ Box(Modifier.fillMaxSize().padding(40.dp), Alignment.Center) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(mensaje, color = Tenue, fontSize = 17.sp, textAlign = TextAlign.Center)
+ Spacer(Modifier.height(20.dp))
+ Surface(
+ color = NeonCian, shape = RoundedCornerShape(50),
+ modifier = Modifier.clip(RoundedCornerShape(50)).clickable(onClick = onClick),
+ ) {
+ Text(
+ accion, color = Vacio, fontSize = 15.sp, fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(horizontal = 22.dp, vertical = 11.dp),
+ )
+ }
+ }
+ }
+}
+
+// Color frío→cálido según temperatura (°C), para las barras de los días.
+private fun colorForTemp(c: Float): Color {
+ val t = ((c + 5f) / 40f).coerceIn(0f, 1f) // -5..35 °C
+ val frio = Color(0xFF3AA0FF)
+ val templado = Color(0xFF7CFF4F)
+ val calido = Color(0xFFFF6B3A)
+ return if (t < 0.5f) lerp(frio, templado, t * 2f) else lerp(templado, calido, (t - 0.5f) * 2f)
+}
+
+private fun hace(ms: Long): String {
+ if (ms <= 0L) return "hace un momento"
+ val s = (System.currentTimeMillis() - ms) / 1000
+ return when {
+ s < 60 -> "hace un momento"
+ s < 3600 -> "hace ${s / 60} min"
+ s < 86_400 -> "hace ${s / 3600} h"
+ else -> "hace ${s / 86_400} d"
+ }
+}
diff --git a/nimbo/src/main/java/com/rizoma/nimbo/ui/Sky.kt b/nimbo/src/main/java/com/rizoma/nimbo/ui/Sky.kt
new file mode 100644
index 0000000..6ce3063
--- /dev/null
+++ b/nimbo/src/main/java/com/rizoma/nimbo/ui/Sky.kt
@@ -0,0 +1,103 @@
+package com.rizoma.nimbo.ui
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.lerp
+import com.rizoma.nimbo.data.Sky
+import com.rizoma.nimbo.data.Weather
+import com.rizoma.nimbo.ui.theme.NeonCian
+import com.rizoma.nimbo.ui.theme.NeonRosa
+import com.rizoma.nimbo.ui.theme.NeonVerde
+
+/**
+ * El "cielo" del fondo: un color base (el vacío teñido) y 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.
+ * 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/nimbo/src/main/java/com/rizoma/nimbo/ui/theme/Theme.kt b/nimbo/src/main/java/com/rizoma/nimbo/ui/theme/Theme.kt
new file mode 100644
index 0000000..0412036
--- /dev/null
+++ b/nimbo/src/main/java/com/rizoma/nimbo/ui/theme/Theme.kt
@@ -0,0 +1,36 @@
+package com.rizoma.nimbo.ui.theme
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+
+// Paleta del rizoma: neón sobre vacío (compartida con Órbita/Trazo/Consola/Matiz).
+val Vacio = Color(0xFF050507)
+val Superficie = Color(0xFF0C0C12)
+val SuperficieAlta = Color(0xFF14141C)
+val NeonVerde = Color(0xFF7CFF4F)
+val NeonRosa = Color(0xFFFF2E88)
+val NeonCian = Color(0xFF00E5FF)
+val Tenue = Color(0xB3FFFFFF)
+val MuyTenue = Color(0x66FFFFFF)
+
+private val EsquemaNimbo = darkColorScheme(
+ primary = NeonCian,
+ onPrimary = Vacio,
+ secondary = NeonRosa,
+ onSecondary = Vacio,
+ tertiary = NeonVerde,
+ onTertiary = Vacio,
+ background = Vacio,
+ onBackground = Color.White,
+ surface = Superficie,
+ onSurface = Color.White,
+ surfaceVariant = SuperficieAlta,
+ onSurfaceVariant = Tenue,
+)
+
+@Composable
+fun NimboTheme(content: @Composable () -> Unit) {
+ MaterialTheme(colorScheme = EsquemaNimbo, content = content)
+}
diff --git a/nimbo/src/main/res/drawable/ic_launcher_fg.xml b/nimbo/src/main/res/drawable/ic_launcher_fg.xml
new file mode 100644
index 0000000..bf725e7
--- /dev/null
+++ b/nimbo/src/main/res/drawable/ic_launcher_fg.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="108dp"
+ android:height="108dp"
+ android:viewportWidth="108"
+ android:viewportHeight="108">
+ <!-- sol asomando -->
+ <path
+ android:pathData="M31 40 a11 11 0 1 0 22 0 a11 11 0 1 0 -22 0"
+ android:strokeColor="#00E5FF"
+ android:strokeWidth="3" />
+ <!-- nube -->
+ <path android:fillColor="#DCE6F2" android:pathData="M34 62 a12 12 0 1 0 24 0 a12 12 0 1 0 -24 0" />
+ <path android:fillColor="#DCE6F2" android:pathData="M47 57 a15 15 0 1 0 30 0 a15 15 0 1 0 -30 0" />
+ <path android:fillColor="#DCE6F2" android:pathData="M62 63 a11 11 0 1 0 22 0 a11 11 0 1 0 -22 0" />
+ <path android:fillColor="#DCE6F2" android:pathData="M40 60 h38 v12 h-38 z" />
+ <!-- lluvia -->
+ <path
+ android:pathData="M50 78 l-4 9 M64 78 l-4 9"
+ android:strokeColor="#FF2E88"
+ android:strokeWidth="3"
+ android:strokeLineCap="round" />
+</vector>
diff --git a/nimbo/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/nimbo/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..088fcce
--- /dev/null
+++ b/nimbo/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+ <background android:drawable="@color/ic_bg" />
+ <foreground android:drawable="@drawable/ic_launcher_fg" />
+</adaptive-icon>
diff --git a/nimbo/src/main/res/values/colors.xml b/nimbo/src/main/res/values/colors.xml
new file mode 100644
index 0000000..917256d
--- /dev/null
+++ b/nimbo/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <color name="ic_bg">#050507</color>
+</resources>
diff --git a/nimbo/src/main/res/values/strings.xml b/nimbo/src/main/res/values/strings.xml
new file mode 100644
index 0000000..425cdde
--- /dev/null
+++ b/nimbo/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="app_name">nimbo</string>
+</resources>
diff --git a/nimbo/src/main/res/values/themes.xml b/nimbo/src/main/res/values/themes.xml
new file mode 100644
index 0000000..7b917c9
--- /dev/null
+++ b/nimbo/src/main/res/values/themes.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <style name="Theme.Nimbo" parent="android:Theme.Material.NoActionBar">
+ <item name="android:windowBackground">#050507</item>
+ <item name="android:statusBarColor">@android:color/transparent</item>
+ <item name="android:navigationBarColor">@android:color/transparent</item>
+ <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
+ </style>
+</resources>
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 6c08155..17ccf4b 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -24,3 +24,4 @@ include(":launcher")
include(":keyboard")
include(":consola")
include(":matiz")
+include(":nimbo")