Órbita: cinta de 5 páginas — continuidad del átomo + zonas vivas
El launcher pasa de una sola vista a cinco páginas que se arrastran de izquierda a derecha; el átomo es UNA pieza que se desliza tras ellas (continuidad real), no cinco copias. Páginas: 1.clima · 2.nuevas+pedazo · 3.centro del átomo · 4.pedazo+expulsadas · 5.desterradas. - Paginado continuo propio (pos 0..4) con draggable + animate/spring; el cuerpo no se recompone por fotograma (graphicsLayer/derivedStateOf leen pos diferido). - nuevas: apps recién instaladas (<3 días, sin abrir) flotando caóticamente "esperando entrar"; al abrirlas entran al átomo. - expulsadas: las quitadas del átomo se alborotan (jitter ∝ turbulencia) y se calman con el tiempo; a los 7 días pasan a desterradas (ya quietas, página 5). AppNode gana firstInstallTime y hiddenAt; UsageStore sella el instante de expulsión (snapshot() unifica toda la memoria orbital en una lectura). - clima (página 1): temperatura+condición sobre el cielo vivo; abre Nimbo si está. - Optimización: cada zona se compone solo cerca de su página y el clima solo se pide (permiso+red) al acercarse — mientras no se abra, no se carga. Escrito NO compilado aquí. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Semantic delta
No definition changed: this change is text, documentation or data.
7 files · +584 −57
- launcher/src/main/java/com/rizoma/orbita/data/AppRepository.kt +8 −1
- launcher/src/main/java/com/rizoma/orbita/data/UsageStore.kt +43 −2
- launcher/src/main/java/com/rizoma/orbita/model/AppNode.kt +5 −0
- launcher/src/main/java/com/rizoma/orbita/model/Buckets.kt +20 −0
- launcher/src/main/java/com/rizoma/orbita/ui/OrbitApp.kt +132 −33
- launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt +68 −21
- launcher/src/main/java/com/rizoma/orbita/ui/Pages.kt +308 −0
diff --git a/launcher/src/main/java/com/rizoma/orbita/data/AppRepository.kt b/launcher/src/main/java/com/rizoma/orbita/data/AppRepository.kt
index 83bc016..cc55e29 100644
--- a/launcher/src/main/java/com/rizoma/orbita/data/AppRepository.kt
+++ b/launcher/src/main/java/com/rizoma/orbita/data/AppRepository.kt
@@ -18,16 +18,23 @@ class AppRepository(context: Context) {
fun loadApps(): List<AppNode> {
val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
val self = appContext.packageName
+ // Fecha de instalación por paquete (cara de consultar): se cachea, ya que
+ // un mismo paquete puede aportar varias actividades lanzables.
+ val installed = HashMap<String, Long>()
return pm.queryIntentActivities(intent, 0)
.asSequence()
.filter { it.activityInfo.packageName != self }
.map { ri ->
+ val pkg = ri.activityInfo.packageName
val icon = ri.loadIcon(pm).toBitmap(ICON_PX, ICON_PX).asImageBitmap()
AppNode(
- packageName = ri.activityInfo.packageName,
+ packageName = pkg,
activityName = ri.activityInfo.name,
label = ri.loadLabel(pm).toString(),
icon = icon,
+ firstInstallTime = installed.getOrPut(pkg) {
+ runCatching { pm.getPackageInfo(pkg, 0).firstInstallTime }.getOrDefault(0L)
+ },
)
}
.toList()
diff --git a/launcher/src/main/java/com/rizoma/orbita/data/UsageStore.kt b/launcher/src/main/java/com/rizoma/orbita/data/UsageStore.kt
index 190a892..0c30323 100644
--- a/launcher/src/main/java/com/rizoma/orbita/data/UsageStore.kt
+++ b/launcher/src/main/java/com/rizoma/orbita/data/UsageStore.kt
@@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
+import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
@@ -12,6 +13,15 @@ import kotlinx.coroutines.flow.map
private val Context.orbitaStore: DataStore<Preferences> by preferencesDataStore(name = "orbita_usage")
+/** Toda la memoria orbital de una sola lectura del store. */
+data class UsageSnapshot(
+ val counts: Map<String, Int>,
+ val pinned: Set<String>,
+ val hidden: Set<String>,
+ val penalties: Map<String, Int>,
+ val hiddenAt: Map<String, Long>,
+)
+
/**
* Memoria orbital: cuántas veces se ha lanzado cada nodo (su calor) y cuáles están fijados.
* El calor define a qué órbita cae el nodo; lo fijado nunca se desprende del centro.
@@ -20,6 +30,30 @@ class UsageStore(context: Context) {
private val store = context.applicationContext.orbitaStore
+ /** Una foto completa de la memoria orbital (todo deriva de la misma lectura). */
+ fun snapshot(): Flow<UsageSnapshot> = store.data.map { prefs ->
+ val counts = HashMap<String, Int>()
+ val penalties = HashMap<String, Int>()
+ val hiddenAt = HashMap<String, Long>()
+ prefs.asMap().forEach { (key, value) ->
+ when {
+ key.name.startsWith(COUNT_PREFIX) && value is Int ->
+ counts[key.name.removePrefix(COUNT_PREFIX)] = value
+ key.name.startsWith(PENALTY_PREFIX) && value is Int ->
+ penalties[key.name.removePrefix(PENALTY_PREFIX)] = value
+ key.name.startsWith(HIDDENAT_PREFIX) && value is Long ->
+ hiddenAt[key.name.removePrefix(HIDDENAT_PREFIX)] = value
+ }
+ }
+ UsageSnapshot(
+ counts = counts,
+ pinned = prefs[PINNED] ?: emptySet(),
+ hidden = prefs[HIDDEN] ?: emptySet(),
+ penalties = penalties,
+ hiddenAt = hiddenAt,
+ )
+ }
+
fun counts(): Flow<Map<String, Int>> = store.data.map { prefs ->
buildMap {
prefs.asMap().forEach { (key, value) ->
@@ -61,15 +95,21 @@ class UsageStore(context: Context) {
}
}
- /** Retira/devuelve un nodo al átomo. Al ocultarlo también lo suelta del centro. */
- suspend fun setHidden(nodeKey: String, hidden: Boolean) {
+ /**
+ * Expulsa/devuelve un nodo del átomo. Al expulsarlo lo suelta del centro y
+ * sella el instante ([nowMs]) para que se vaya calmando hacia el destierro.
+ */
+ suspend fun setHidden(nodeKey: String, hidden: Boolean, nowMs: Long) {
store.edit { prefs ->
val current = prefs[HIDDEN]?.toMutableSet() ?: mutableSetOf()
+ val atKey = longPreferencesKey(HIDDENAT_PREFIX + nodeKey)
if (hidden) {
current.add(nodeKey)
prefs[PINNED]?.let { prefs[PINNED] = it - nodeKey }
+ prefs[atKey] = nowMs
} else {
current.remove(nodeKey)
+ prefs.remove(atKey)
}
prefs[HIDDEN] = current
}
@@ -88,6 +128,7 @@ class UsageStore(context: Context) {
private companion object {
const val COUNT_PREFIX = "c:"
const val PENALTY_PREFIX = "p:"
+ const val HIDDENAT_PREFIX = "h:"
const val PUSH_BUFFER = 8
val PINNED = stringSetPreferencesKey("pinned")
val HIDDEN = stringSetPreferencesKey("hidden")
diff --git a/launcher/src/main/java/com/rizoma/orbita/model/AppNode.kt b/launcher/src/main/java/com/rizoma/orbita/model/AppNode.kt
index 583b94e..67a266a 100644
--- a/launcher/src/main/java/com/rizoma/orbita/model/AppNode.kt
+++ b/launcher/src/main/java/com/rizoma/orbita/model/AppNode.kt
@@ -5,6 +5,9 @@ import androidx.compose.ui.graphics.ImageBitmap
/**
* Un nodo del rizoma: una app lanzable con su calor de uso.
* [score] es el peso orbital — más alto = órbita más interna.
+ * [firstInstallTime] (epoch ms) detecta apps recién instaladas ("nuevas").
+ * [hiddenAt] (epoch ms) marca cuándo se expulsó del átomo, para que las
+ * expulsadas se vayan calmando con el tiempo hasta quedar desterradas.
*/
data class AppNode(
val packageName: String,
@@ -14,6 +17,8 @@ data class AppNode(
val score: Float = 0f,
val pinned: Boolean = false,
val hidden: Boolean = false,
+ val firstInstallTime: Long = 0L,
+ val hiddenAt: Long = 0L,
) {
val key: String get() = "$packageName/$activityName"
}
diff --git a/launcher/src/main/java/com/rizoma/orbita/model/Buckets.kt b/launcher/src/main/java/com/rizoma/orbita/model/Buckets.kt
new file mode 100644
index 0000000..74e78fc
--- /dev/null
+++ b/launcher/src/main/java/com/rizoma/orbita/model/Buckets.kt
@@ -0,0 +1,20 @@
+package com.rizoma.orbita.model
+
+/**
+ * El rizoma repartido en sus cinco zonas espaciales (de izquierda a derecha):
+ * el clima vive aparte; aquí van las cuatro que dependen de las apps.
+ *
+ * - [atom]: el centro del átomo (apps establecidas, no expulsadas ni nuevas).
+ * - [nuevas]: recién instaladas, rotando caóticamente "esperando entrar".
+ * - [expulsadas]: retiradas hace poco; se alborotan y se van calmando.
+ * - [desterradas]: expulsadas hace tiempo; ya quietas.
+ */
+data class Buckets(
+ val atom: List<AppNode>,
+ val nuevas: List<AppNode>,
+ val expulsadas: List<ExpelledNode>,
+ val desterradas: List<AppNode>,
+)
+
+/** Una app expulsada con su [turbulence] (1 = recién echada, 0 = calmada). */
+data class ExpelledNode(val node: AppNode, val turbulence: Float)
diff --git a/launcher/src/main/java/com/rizoma/orbita/ui/OrbitApp.kt b/launcher/src/main/java/com/rizoma/orbita/ui/OrbitApp.kt
index edf3bdf..a9da2d4 100644
--- a/launcher/src/main/java/com/rizoma/orbita/ui/OrbitApp.kt
+++ b/launcher/src/main/java/com/rizoma/orbita/ui/OrbitApp.kt
@@ -5,44 +5,61 @@ import android.content.pm.PackageManager
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animate
+import androidx.compose.animation.core.spring
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.draggable
+import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
-import androidx.core.content.ContextCompat
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
@@ -50,8 +67,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
+import androidx.core.content.ContextCompat
import androidx.lifecycle.viewmodel.compose.viewModel
-import androidx.compose.runtime.collectAsState
import com.rizoma.orbita.R
import com.rizoma.orbita.model.AppNode
import com.rizoma.orbita.ui.theme.NeonCian
@@ -60,49 +77,131 @@ import com.rizoma.orbita.ui.theme.NeonVerde
import com.rizoma.orbita.ui.theme.Superficie
import com.rizoma.orbita.ui.theme.Tenue
import com.rizoma.orbita.ui.theme.Vacio
+import kotlin.math.abs
+import kotlin.math.roundToInt
+import kotlinx.coroutines.launch
+
+// El rizoma como cinta de cinco páginas (0..4). El centro del átomo es la 2.
+// 0 clima · 1 nuevas+pedazo · 2 centro del átomo · 3 pedazo+expulsadas · 4 desterradas
+private const val CENTER = 2f
+private const val FLING = 0.09f // cuánto pesa la velocidad del gesto al decidir página
@Composable
fun OrbitApp(vm: OrbitaViewModel = viewModel()) {
- val nodes by vm.nodes.collectAsState()
+ val buckets by vm.buckets.collectAsState()
+ val allNodes by vm.allNodes.collectAsState()
val sky by vm.sky.collectAsState()
+ val weather by vm.weather.collectAsState()
+
var searchOpen by remember { mutableStateOf(false) }
var menuNode by remember { mutableStateOf<AppNode?>(null) }
- // El cielo del fondo se tiñe con el clima local: pedimos ubicación aproximada
- // una vez y, con permiso, refrescamos el clima.
val context = LocalContext.current
+ val nimboInstalled = remember {
+ context.packageManager.getLaunchIntentForPackage("com.rizoma.nimbo") != null
+ }
+
+ // Posición continua entre páginas. Solo se lee de forma diferida (graphicsLayer,
+ // derivedStateOf, gestos): el cuerpo no se recompone en cada fotograma del arrastre.
+ var pos by remember { mutableFloatStateOf(CENTER) }
+ val scope = rememberCoroutineScope()
+
+ // Qué zonas existen en RAM: cada una se compone solo cerca de su página.
+ val showClima by remember { derivedStateOf { pos < 1.5f } }
+ val showNuevas by remember { derivedStateOf { pos > 0.3f && pos < 2.4f } }
+ val showExpulsadas by remember { derivedStateOf { pos > 1.6f && pos < 3.7f } }
+ val showDesterradas by remember { derivedStateOf { pos > 3.3f } }
+ val centered by remember { derivedStateOf { abs(pos - CENTER) < 0.02f } }
+
+ // Clima perezoso: solo se pide (permiso + red) al acercarse a su página.
val permLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> if (granted) vm.refreshWeather() }
- LaunchedEffect(Unit) {
- val granted = ContextCompat.checkSelfPermission(
- context, Manifest.permission.ACCESS_COARSE_LOCATION
- ) == PackageManager.PERMISSION_GRANTED
- if (granted) vm.refreshWeather()
- else permLauncher.launch(Manifest.permission.ACCESS_COARSE_LOCATION)
+ var askedWeather by remember { mutableStateOf(false) }
+ LaunchedEffect(showClima) {
+ if (showClima && !askedWeather) {
+ askedWeather = true
+ val granted = ContextCompat.checkSelfPermission(
+ context, Manifest.permission.ACCESS_COARSE_LOCATION,
+ ) == PackageManager.PERMISSION_GRANTED
+ if (granted) vm.ensureWeather()
+ else permLauncher.launch(Manifest.permission.ACCESS_COARSE_LOCATION)
+ }
}
- // Como launcher: "atrás" cierra la búsqueda o no hace nada (nunca sale al sistema).
- BackHandler(enabled = true) {
+ // Como launcher: "atrás" cierra la búsqueda, o vuelve al centro del átomo.
+ BackHandler(enabled = searchOpen || !centered) {
if (searchOpen) searchOpen = false
+ else scope.launch { animate(pos, CENTER) { v, _ -> pos = v } }
}
- // Lo que orbita: el átomo sin los nodos retirados. Los ocultos siguen siendo
- // alcanzables desde la búsqueda, para poder devolverlos.
- val orbitNodes = remember(nodes) { nodes.filterNot { it.hidden } }
+ BoxWithConstraints(Modifier.fillMaxSize().background(Vacio)) {
+ val wPx = with(LocalDensity.current) { maxWidth.toPx() }
+ val drag = rememberDraggableState { delta -> pos = (pos - delta / wPx).coerceIn(0f, 4f) }
+
+ Box(
+ Modifier
+ .fillMaxSize()
+ .draggable(
+ state = drag,
+ orientation = Orientation.Horizontal,
+ onDragStopped = { velocity ->
+ val target = (pos - velocity / wPx * FLING).roundToInt().coerceIn(0, 4).toFloat()
+ animate(
+ initialValue = pos,
+ targetValue = target,
+ animationSpec = spring(dampingRatio = 0.85f, stiffness = Spring.StiffnessMediumLow),
+ ) { v, _ -> pos = v }
+ },
+ )
+ ) {
+ // El cielo es el telón continuo: no se desplaza con las páginas.
+ DynamicBackground(palette = sky)
+
+ // El átomo: UNA sola pieza que se desliza tras las páginas (de ahí la
+ // sensación de continuidad). En las páginas 1 y 3 asoma su borde.
+ Box(Modifier.fillMaxSize().graphicsLayer { translationX = (CENTER - pos) * wPx }) {
+ OrbitLayout(
+ nodes = buckets.atom,
+ onLaunch = vm::launch,
+ onLongPress = { menuNode = it },
+ onCenterTap = { searchOpen = true },
+ )
+ }
+
+ if (showClima) {
+ Box(Modifier.fillMaxSize().graphicsLayer { translationX = (0f - pos) * wPx }) {
+ ClimaPage(weather, nimboInstalled) { vm.launchPackage("com.rizoma.nimbo") }
+ }
+ }
+ if (showNuevas) {
+ Box(Modifier.fillMaxSize().graphicsLayer { translationX = (1f - pos) * wPx }) {
+ NuevasZone(buckets.nuevas, vm::launch) { menuNode = it }
+ }
+ }
+ if (showExpulsadas) {
+ Box(Modifier.fillMaxSize().graphicsLayer { translationX = (3f - pos) * wPx }) {
+ ExpulsadasZone(buckets.expulsadas, vm::launch) { menuNode = it }
+ }
+ }
+ if (showDesterradas) {
+ Box(Modifier.fillMaxSize().graphicsLayer { translationX = (4f - pos) * wPx }) {
+ DesterradasZone(buckets.desterradas, vm::launch) { menuNode = it }
+ }
+ }
- Box(Modifier.fillMaxSize().background(Vacio)) {
- DynamicBackground(palette = sky)
- OrbitLayout(
- nodes = orbitNodes,
- onLaunch = vm::launch,
- onLongPress = { menuNode = it },
- onCenterTap = { searchOpen = true },
- )
+ PageDots(
+ progress = { pos },
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .windowInsetsPadding(WindowInsets.systemBars)
+ .padding(bottom = 22.dp),
+ )
+ }
if (searchOpen) {
SearchOverlay(
- nodes = nodes,
+ nodes = allNodes,
onLaunch = {
vm.launch(it)
searchOpen = false
@@ -165,7 +264,7 @@ private fun SearchOverlay(
singleLine = true,
textStyle = TextStyle(color = NeonVerde, fontSize = 26.sp, fontWeight = FontWeight.Light),
cursorBrush = SolidColor(NeonRosa),
- keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(imeAction = ImeAction.Search),
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
modifier = Modifier.fillMaxWidth().focusRequester(focus),
decorationBox = { inner ->
if (query.isEmpty()) {
@@ -185,7 +284,7 @@ private fun SearchOverlay(
LazyColumn(Modifier.fillMaxSize()) {
items(filtered, key = { it.key }) { node ->
- // Los ocultos se ven atenuados; un toque largo abre su menú (para devolverlos).
+ // Los expulsados se ven atenuados; un toque largo abre su menú (para devolverlos).
val dim = if (node.hidden) 0.4f else 1f
Row(
Modifier
@@ -206,7 +305,7 @@ private fun SearchOverlay(
Spacer(Modifier.width(16.dp))
Text(
text = node.label,
- color = androidx.compose.ui.graphics.Color.White.copy(alpha = dim),
+ color = Color.White.copy(alpha = dim),
fontSize = 16.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -244,14 +343,14 @@ private fun NodeMenu(
Column(Modifier.width(240.dp).padding(vertical = 10.dp)) {
Text(
text = node.label,
- color = androidx.compose.ui.graphics.Color.White,
+ color = Color.White,
fontSize = 15.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 10.dp),
)
- // Un nodo retirado solo se puede devolver, consultar o desinstalar:
+ // Un nodo expulsado solo se puede devolver, consultar o desinstalar:
// fijar/alejar no aplican mientras está fuera del átomo.
if (!node.hidden) {
MenuRow(
@@ -274,7 +373,7 @@ private fun NodeMenu(
}
@Composable
-private fun MenuRow(label: String, accent: androidx.compose.ui.graphics.Color, onClick: () -> Unit) {
+private fun MenuRow(label: String, accent: Color, onClick: () -> Unit) {
Row(
Modifier
.fillMaxWidth()
@@ -284,6 +383,6 @@ private fun MenuRow(label: String, accent: androidx.compose.ui.graphics.Color, o
) {
Box(Modifier.size(6.dp).clip(CircleShape).background(accent))
Spacer(Modifier.width(14.dp))
- Text(text = label, color = androidx.compose.ui.graphics.Color.White, fontSize = 15.sp)
+ Text(text = label, color = Color.White, fontSize = 15.sp)
}
}
diff --git a/launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt b/launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt
index 9ae1736..a371fc7 100644
--- a/launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt
+++ b/launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt
@@ -15,13 +15,17 @@ import com.rizoma.orbita.data.SkyRepository
import com.rizoma.orbita.data.UsageStore
import com.rizoma.orbita.data.Weather
import com.rizoma.orbita.model.AppNode
+import com.rizoma.orbita.model.Buckets
+import com.rizoma.orbita.model.ExpelledNode
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.util.Calendar
@@ -33,32 +37,55 @@ class OrbitaViewModel(app: Application) : AndroidViewModel(app) {
private val rawApps = MutableStateFlow<List<AppNode>>(emptyList())
- /** Nodos ya ponderados y ordenados: fijados primero, luego por calor, luego alfabético. */
- val nodes: StateFlow<List<AppNode>> = combine(
- rawApps,
- usage.counts(),
- usage.pinned(),
- usage.penalties(),
- usage.hidden(),
- ) { apps, counts, pinned, penalties, hidden ->
+ /** Todas las apps ya ponderadas y ordenadas (fijados, calor, alfabético). Para la búsqueda. */
+ val allNodes: StateFlow<List<AppNode>> = combine(rawApps, usage.snapshot()) { apps, u ->
apps.map { node ->
node.copy(
- score = (counts[node.key] ?: 0).toFloat(),
- pinned = node.key in pinned,
- hidden = node.key in hidden,
+ score = (u.counts[node.key] ?: 0).toFloat(),
+ pinned = node.key in u.pinned,
+ hidden = node.key in u.hidden,
+ hiddenAt = u.hiddenAt[node.key] ?: 0L,
)
}.sortedWith(
- // El orden define la órbita: fijados al centro, luego por calor efectivo
- // (calor menos penalización por "alejar"), luego alfabético.
compareByDescending<AppNode> { it.pinned }
- .thenByDescending { it.score - (penalties[it.key] ?: 0).toFloat() }
+ .thenByDescending { it.score - (u.penalties[it.key] ?: 0).toFloat() }
.thenBy { it.label.lowercase() }
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
- // --- Cielo del fondo: hora del día + clima local. ---
+ /** Las apps repartidas en las cuatro zonas (átomo, nuevas, expulsadas, desterradas). */
+ val buckets: StateFlow<Buckets> = allNodes
+ .map { classify(it) }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), Buckets(emptyList(), emptyList(), emptyList(), emptyList()))
+
+ private fun classify(nodes: List<AppNode>): Buckets {
+ val now = System.currentTimeMillis()
+ val nuevas = nodes.filter {
+ !it.hidden && it.score == 0f &&
+ it.firstInstallTime > 0L && now - it.firstInstallTime < NEW_WINDOW
+ }.sortedByDescending { it.firstInstallTime }
+ val nuevasKeys = nuevas.mapTo(HashSet()) { it.key }
+
+ val atom = nodes.filter { !it.hidden && it.key !in nuevasKeys }
+
+ val expulsadas = ArrayList<ExpelledNode>()
+ val desterradas = ArrayList<AppNode>()
+ for (n in nodes.filter { it.hidden }) {
+ val age = now - n.hiddenAt
+ if (n.hiddenAt > 0L && age < CALM_WINDOW) {
+ expulsadas.add(ExpelledNode(n, (1f - age.toFloat() / CALM_WINDOW).coerceIn(0f, 1f)))
+ } else {
+ desterradas.add(n)
+ }
+ }
+ return Buckets(atom, nuevas, expulsadas, desterradas.sortedBy { it.label.lowercase() })
+ }
+
+ // --- Cielo del fondo: hora del día + clima local (perezoso). ---
- private val weather = MutableStateFlow<Weather?>(null)
+ private val _weather = MutableStateFlow<Weather?>(null)
+ /** Clima actual (null hasta que se visita la página de clima). */
+ val weather: StateFlow<Weather?> = _weather.asStateFlow()
/** Marca la hora actual (con fracción) y se reemite cada minuto. */
private val clock = flow {
@@ -68,7 +95,7 @@ class OrbitaViewModel(app: Application) : AndroidViewModel(app) {
}
}
- val sky: StateFlow<SkyPalette> = combine(clock, weather) { hour, w ->
+ val sky: StateFlow<SkyPalette> = combine(clock, _weather) { hour, w ->
skyPaletteFor(hour, w)
}.stateIn(
viewModelScope,
@@ -76,10 +103,18 @@ class OrbitaViewModel(app: Application) : AndroidViewModel(app) {
skyPaletteFor(currentHourFraction(), null),
)
- /** Refresca el clima local (best-effort). Llamar tras conceder ubicación. */
+ /**
+ * Refresca el clima local (best-effort, una sola vez salvo que se fuerce).
+ * Se llama al acercarse a la página de clima: mientras no se abra, no se carga.
+ */
+ fun ensureWeather() {
+ if (_weather.value != null) return
+ refreshWeather()
+ }
+
fun refreshWeather() {
viewModelScope.launch(Dispatchers.IO) {
- SkyRepository(getApplication()).fetch()?.let { weather.value = it }
+ SkyRepository(getApplication()).fetch()?.let { _weather.value = it }
}
}
@@ -118,13 +153,20 @@ class OrbitaViewModel(app: Application) : AndroidViewModel(app) {
viewModelScope.launch { usage.record(node.key) }
}
+ /** Abre una app por nombre de paquete (p.ej. Nimbo desde la página de clima). */
+ fun launchPackage(pkg: String): Boolean {
+ val intent = getApplication<Application>().packageManager.getLaunchIntentForPackage(pkg)
+ ?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) ?: return false
+ return runCatching { getApplication<Application>().startActivity(intent); true }.getOrDefault(false)
+ }
+
fun togglePin(node: AppNode) {
viewModelScope.launch { usage.setPinned(node.key, !node.pinned) }
}
- /** Retira el nodo del átomo (o lo devuelve). No desinstala: la app sigue ahí. */
+ /** Expulsa el nodo del átomo (o lo devuelve). No desinstala: la app sigue ahí. */
fun toggleHidden(node: AppNode) {
- viewModelScope.launch { usage.setHidden(node.key, !node.hidden) }
+ viewModelScope.launch { usage.setHidden(node.key, !node.hidden, System.currentTimeMillis()) }
}
/** Aleja el nodo a órbitas externas (usa su calor actual como referencia). */
@@ -150,4 +192,9 @@ class OrbitaViewModel(app: Application) : AndroidViewModel(app) {
override fun onCleared() {
runCatching { getApplication<Application>().unregisterReceiver(pkgReceiver) }
}
+
+ private companion object {
+ const val NEW_WINDOW = 3L * 24 * 3600 * 1000 // "nueva" durante 3 días
+ const val CALM_WINDOW = 7L * 24 * 3600 * 1000 // se calma/destierra en 7 días
+ }
}
diff --git a/launcher/src/main/java/com/rizoma/orbita/ui/Pages.kt b/launcher/src/main/java/com/rizoma/orbita/ui/Pages.kt
new file mode 100644
index 0000000..eed715a
--- /dev/null
+++ b/launcher/src/main/java/com/rizoma/orbita/ui/Pages.kt
@@ -0,0 +1,308 @@
+package com.rizoma.orbita.ui
+
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.BoxWithConstraintsScope
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.systemBars
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.graphics.lerp
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.rizoma.orbita.data.Sky
+import com.rizoma.orbita.data.Weather
+import com.rizoma.orbita.model.AppNode
+import com.rizoma.orbita.model.ExpelledNode
+import com.rizoma.orbita.ui.theme.NeonCian
+import com.rizoma.orbita.ui.theme.NeonRosa
+import com.rizoma.orbita.ui.theme.NeonVerde
+import com.rizoma.orbita.ui.theme.Tenue
+import kotlin.math.PI
+import kotlin.math.abs
+import kotlin.math.cos
+import kotlin.math.roundToInt
+import kotlin.math.sin
+
+private const val TAU = (2 * PI).toFloat()
+private val MuyTenue = Color(0x66FFFFFF)
+
+// ─────────────────────────── Página 1: clima ───────────────────────────
+
+@Composable
+fun ClimaPage(weather: Weather?, nimboInstalled: Boolean, onOpenNimbo: () -> Unit) {
+ Box(
+ Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.systemBars).padding(28.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text("clima", color = MuyTenue, fontSize = 12.sp, letterSpacing = 4.sp)
+ Spacer(Modifier.height(20.dp))
+ if (weather == null) {
+ Text("leyendo el cielo…", color = Tenue, fontSize = 18.sp)
+ } else {
+ Text(
+ "${weather.temperatureC.roundToInt()}°",
+ color = Color.White, fontSize = 96.sp, fontWeight = FontWeight.Light,
+ )
+ Text(condicion(weather), color = Tenue, fontSize = 22.sp)
+ }
+ if (nimboInstalled) {
+ Spacer(Modifier.height(36.dp))
+ Text(
+ "abrir nimbo →",
+ color = NeonCian, fontSize = 14.sp, fontWeight = FontWeight.Medium,
+ modifier = Modifier
+ .clip(CircleShape)
+ .clickable(onClick = onOpenNimbo)
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ )
+ }
+ }
+ }
+}
+
+private fun condicion(w: Weather): String = when (w.sky) {
+ Sky.CLEAR -> if (w.isDay) "despejado" else "noche despejada"
+ Sky.CLOUDY -> "parcialmente nublado"
+ Sky.OVERCAST -> "cubierto"
+ Sky.FOG -> "niebla"
+ Sky.RAIN -> "lluvia"
+ Sky.SNOW -> "nieve"
+ Sky.STORM -> "tormenta"
+}
+
+// ─────────────────────── Página 2 (izq): nuevas ────────────────────────
+
+/** Apps recién instaladas, flotando caóticamente en la franja izquierda, esperando entrar. */
+@Composable
+fun NuevasZone(nodes: List<AppNode>, onLaunch: (AppNode) -> Unit, onLong: (AppNode) -> Unit) {
+ val time by rememberInfiniteTransition(label = "nuevas")
+ .animateFloat(0f, TAU, infiniteRepeatable(tween(24_000, easing = LinearEasing)), label = "t")
+ val density = LocalDensity.current
+
+ BoxWithConstraints(Modifier.fillMaxSize()) {
+ val w = with(density) { maxWidth.toPx() }
+ val h = with(density) { maxHeight.toPx() }
+ val iconHalf = with(density) { 26.dp.toPx() }
+
+ ZoneTitle("nuevas", "esperando entrar", NeonCian, Alignment.TopStart)
+ if (nodes.isEmpty()) ZoneEmpty("sin apps nuevas", Alignment.CenterStart)
+
+ nodes.forEach { node ->
+ val ph = seed(node.key, 1) * TAU
+ val fx = 0.55f + seed(node.key, 2) * 1.2f
+ val fy = 0.50f + seed(node.key, 3) * 1.1f
+ val ax = 0.05f + seed(node.key, 4) * 0.07f
+ val ay = 0.06f + seed(node.key, 5) * 0.08f
+ val ox = 0.16f + seed(node.key, 6) * 0.40f // franja izquierda (~16–56%)
+ val oy = 0.20f + seed(node.key, 7) * 0.58f
+ val px = w * (ox + ax * cos(time * fx + ph))
+ val py = h * (oy + ay * sin(time * fy + ph))
+ val tilt = sin(time * 0.6f + ph) * 9f
+ ZoneIcon(
+ node = node, sizeDp = 52, alpha = 1f, onLaunch = onLaunch, onLong = onLong,
+ modifier = Modifier
+ .offset { IntOffset((px - iconHalf).roundToInt(), (py - iconHalf).roundToInt()) }
+ .graphicsLayer { rotationZ = tilt },
+ )
+ }
+ }
+}
+
+// ─────────────────── Página 4 (der): expulsadas ────────────────────────
+
+/** Apps recién retiradas: se alborotan (jitter ∝ turbulencia) y se van calmando. */
+@Composable
+fun ExpulsadasZone(nodes: List<ExpelledNode>, onLaunch: (AppNode) -> Unit, onLong: (AppNode) -> Unit) {
+ val time by rememberInfiniteTransition(label = "expulsadas")
+ .animateFloat(0f, TAU, infiniteRepeatable(tween(9_000, easing = LinearEasing)), label = "t")
+ val density = LocalDensity.current
+
+ BoxWithConstraints(Modifier.fillMaxSize()) {
+ val w = with(density) { maxWidth.toPx() }
+ val h = with(density) { maxHeight.toPx() }
+ val iconHalf = with(density) { 24.dp.toPx() }
+
+ ZoneTitle("expulsadas", "se calman con el tiempo", NeonRosa, Alignment.TopEnd)
+ if (nodes.isEmpty()) ZoneEmpty("nada expulsado", Alignment.CenterEnd)
+
+ nodes.forEachIndexed { i, ex ->
+ val node = ex.node
+ val turb = ex.turbulence
+ val col = i % 3
+ val row = i / 3
+ val baseX = 0.48f + col * 0.17f // mitad derecha
+ val baseY = 0.24f + row * 0.14f
+ val ph = seed(node.key, 1) * TAU
+ val jx = turb * 0.045f * cos(time * 2.3f + ph)
+ val jy = turb * 0.055f * sin(time * 2.7f + ph * 1.3f)
+ val px = w * (baseX + jx)
+ val py = h * (baseY + jy)
+ ZoneIcon(
+ node = node, sizeDp = 48, alpha = 0.55f + 0.45f * turb,
+ onLaunch = onLaunch, onLong = onLong,
+ modifier = Modifier
+ .offset { IntOffset((px - iconHalf).roundToInt(), (py - iconHalf).roundToInt()) }
+ .graphicsLayer { rotationZ = turb * 10f * sin(time * 3f + ph) },
+ )
+ }
+ }
+}
+
+// ─────────────────────── Página 5: desterradas ─────────────────────────
+
+/** Apps expulsadas hace tiempo: ya quietas, en una cuadrícula calmada. */
+@Composable
+fun DesterradasZone(nodes: List<AppNode>, onLaunch: (AppNode) -> Unit, onLong: (AppNode) -> Unit) {
+ Column(
+ Modifier
+ .fillMaxSize()
+ .windowInsetsPadding(WindowInsets.systemBars)
+ .padding(horizontal = 24.dp)
+ .verticalScroll(rememberScrollState()),
+ ) {
+ Spacer(Modifier.height(20.dp))
+ Text("desterradas", color = NeonVerde, fontSize = 22.sp, fontWeight = FontWeight.SemiBold)
+ Text("expulsadas hace tiempo · ya quietas", color = MuyTenue, fontSize = 12.sp)
+ Spacer(Modifier.height(24.dp))
+
+ if (nodes.isEmpty()) {
+ Text("aún no hay apps desterradas", color = Tenue, fontSize = 15.sp)
+ }
+ nodes.chunked(4).forEach { fila ->
+ Row(Modifier.fillMaxWidth().padding(vertical = 10.dp)) {
+ fila.forEach { node ->
+ Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
+ ZoneIcon(node, sizeDp = 52, alpha = 0.5f, onLaunch = onLaunch, onLong = onLong, modifier = Modifier)
+ }
+ }
+ repeat(4 - fila.size) { Spacer(Modifier.weight(1f)) }
+ }
+ }
+ Spacer(Modifier.height(40.dp))
+ }
+}
+
+// ─────────────────────────── compartido ────────────────────────────────
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun ZoneIcon(
+ node: AppNode,
+ sizeDp: Int,
+ alpha: Float,
+ onLaunch: (AppNode) -> Unit,
+ onLong: (AppNode) -> Unit,
+ modifier: Modifier,
+) {
+ Image(
+ bitmap = node.icon,
+ contentDescription = node.label,
+ alpha = alpha,
+ modifier = modifier
+ .size(sizeDp.dp)
+ .clip(CircleShape)
+ .combinedClickable(
+ onClick = { onLaunch(node) },
+ onLongClick = { onLong(node) },
+ ),
+ )
+}
+
+@Composable
+private fun BoxWithConstraintsScope.ZoneTitle(
+ title: String,
+ subtitle: String,
+ accent: Color,
+ align: Alignment,
+) {
+ Column(
+ Modifier
+ .align(align)
+ .windowInsetsPadding(WindowInsets.systemBars)
+ .padding(24.dp),
+ horizontalAlignment = if (align == Alignment.TopEnd) Alignment.End else Alignment.Start,
+ ) {
+ Text(title, color = accent, fontSize = 20.sp, fontWeight = FontWeight.SemiBold)
+ Text(subtitle, color = MuyTenue, fontSize = 12.sp)
+ }
+}
+
+@Composable
+private fun BoxWithConstraintsScope.ZoneEmpty(text: String, align: Alignment) {
+ Text(
+ text, color = Tenue, fontSize = 15.sp,
+ textAlign = TextAlign.Center,
+ maxLines = 2, overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.align(align).padding(40.dp).width(160.dp),
+ )
+}
+
+/** Cinco puntos: el más cercano a la página actual brilla. Lee [progress] al dibujar. */
+@Composable
+fun PageDots(progress: () -> Float, modifier: Modifier) {
+ Canvas(modifier.fillMaxWidth().height(26.dp)) {
+ val p = progress()
+ val n = 5
+ val gap = 18.dp.toPx()
+ val startX = size.width / 2f - gap * (n - 1) / 2f
+ val cy = size.height / 2f
+ for (i in 0 until n) {
+ val d = (1f - abs(p - i).coerceIn(0f, 1f))
+ drawCircle(
+ color = lerp(MuyTenue, NeonVerde, d),
+ radius = (2.5f + 2.5f * d).dp.toPx(),
+ center = Offset(startX + i * gap, cy),
+ )
+ }
+ }
+}
+
+// Pseudoaleatorio estable por clave: misma app → mismos parámetros de movimiento.
+private fun seed(key: String, salt: Int): Float {
+ var h = key.hashCode() xor (salt * 0x27d4eb2d)
+ h *= 0x9E3779B1.toInt()
+ h = h xor (h ushr 15)
+ h *= 0x27d4eb2d
+ h = h xor (h ushr 13)
+ return ((h ushr 9) and 0x3FFFFF) / 4194303f
+}