Órbita: núcleo vivo, ronda sin saltos, orden de búsqueda y marca gioser.net
El centro del átomo ahora deriva de color por la paleta entera (verde → cian → violeta → rosa → ámbar) en 15 minutos: nunca se ve cambiar, solo se nota que el color de hace un rato ya no es el de ahora. Los anillos se tiñen con él. Y brilla más: núcleo blanco pequeño envuelto en un halo muy amplio y difuminado (0.58 del lado menor) que alcanza más allá del primer anillo y respira despacio. La ronda ya no salta. Dos causas y dos arreglos: - La posición se leía en composición (`spin` con `by`) → cada fotograma recomponía todos los nodos y, al saltarse recomposiciones, el icono reaparecía de golpe en otro punto. Ahora el ángulo se calcula dentro de `graphicsLayer`: fase de dibujo, sin recomponer. - El reparto se recalculaba con cada cambio de calor, así que abrir una app reordenaba el átomo a tus espaldas. Ahora las casillas se congelan y solo se recalculan cuando cambia la firma del átomo (qué apps hay, fijadas, alejadas); el calor sube sin mover nada. Y aun entonces cada icono viaja a su casilla con un muelle lento (ángulo desenrollado al camino corto), nunca de golpe. Para que "alejar" siga notándose, AppNode lleva ahora su `penalty` y entra en la firma; el reparto trabaja sobre claves (Slot por clave), así que los iconos que se recargan no arrastran posiciones viejas. Búsqueda: fila de criterios —frecuencia (por defecto, el orden del rizoma), nombre y recientes (fecha de instalación)—, recordado en el DataStore. Marca de la casa: "gioser.net" discreto bajo los puntos de página y al pie de la búsqueda, más README del módulo listo para publicar. Compilado aquí con kotlinc 2.2 + plugin de Compose contra los jars de la caché (no hay JDK 21 para Gradle en este equipo): 0 errores. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016w3QXjA1GHKcJG1sfJAbco
Semántico sayk'uy
Mana ima sutichaypas t'ikrakurqanchu: qillqalla, yachachiylla utaq willaylla t'ikrakurqan.
10 qillqakuna · +386 −74
- launcher/README.md +67 −0
- launcher/src/main/java/com/rizoma/orbita/data/UsageStore.kt +9 −0
- launcher/src/main/java/com/rizoma/orbita/model/AppNode.kt +2 −0
- launcher/src/main/java/com/rizoma/orbita/model/SearchSort.kt +18 −0
- launcher/src/main/java/com/rizoma/orbita/ui/OrbitApp.kt +79 −10
- launcher/src/main/java/com/rizoma/orbita/ui/OrbitLayout.kt +184 −63
- launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt +13 −0
- launcher/src/main/java/com/rizoma/orbita/ui/Pages.kt +1 −1
- launcher/src/main/java/com/rizoma/orbita/ui/theme/Theme.kt +9 −0
- launcher/src/main/res/values/strings.xml +4 −0
diff --git a/launcher/README.md b/launcher/README.md
new file mode 100644
index 0000000..b82621b
--- /dev/null
+++ b/launcher/README.md
@@ -0,0 +1,67 @@
+# Órbita — el launcher del rizoma
+
+Un launcher de Android (`com.rizoma.orbita`) sin rejilla de iconos: las apps
+orbitan un núcleo de luz. Cuanto más se usa una app, más adentro cae su
+órbita; lo fijado no se suelta del centro; lo que estorba se expulsa del
+átomo sin desinstalarlo.
+
+Hecho en **[gioser.net](https://gioser.net)** — parte del rizoma
+(*Órbita · Consola · Matiz · Nimbo · Notas · Cubo · Nonet · Vela*).
+
+## La cinta de cinco páginas
+
+Se arrastra en horizontal; el átomo es una sola pieza que se desliza detrás,
+de ahí la sensación de continuidad. «Atrás» siempre vuelve al centro.
+
+| 0 clima | 1 nuevas | **2 átomo** | 3 expulsadas | 4 desterradas |
+|---|---|---|---|---|
+| hora + cielo local | recién instaladas | la ronda de órbitas | echadas hace poco, aún alborotadas | echadas hace tiempo, ya quietas |
+
+## El núcleo
+
+- **Brilla**: un punto blanco envuelto en un halo muy amplio y difuminado,
+ que alcanza más allá del primer anillo y respira despacio.
+- **Deriva de color**: recorre la paleta entera (verde → cian → violeta →
+ rosa → ámbar) en 15 minutos. Nunca se ve cambiar; solo se nota que el
+ color de hace un rato ya no es el de ahora. Los anillos se tiñen con él.
+- **Se toca para buscar.**
+
+## La ronda no salta
+
+Dos reglas sostienen la órbita:
+
+1. **Las casillas se congelan.** El reparto solo se recalcula cuando cambia
+ la firma del átomo —qué apps hay, cuáles están fijadas, cuáles alejadas—.
+ Usar una app le sube el calor, pero no mueve nada a tus espaldas: el
+ átomo se reordena cuando *tú* actúas sobre él. Y aun entonces, cada icono
+ viaja a su nueva casilla con un muelle lento.
+2. **La vuelta se calcula al dibujar** (`graphicsLayer`), no recomponiendo:
+ va a 60 fps sin depender de que Compose recomponga cada fotograma.
+
+## Buscar
+
+Un toque en el núcleo. La lista se ordena por **frecuencia** (por defecto,
+el orden del rizoma: fijados primero, luego el calor), por **nombre** o por
+**recientes** (fecha de instalación). El criterio se recuerda.
+
+## Gestos
+
+- Toque: abrir. Toque largo: menú (fijar · alejar · quitar del átomo · info
+ · desinstalar).
+- Arrastre horizontal: cambiar de página. Atrás: volver al centro.
+
+## Permisos
+
+`INTERNET` + `ACCESS_COARSE_LOCATION` solo para el clima de la página 0, y
+se piden **al acercarse a esa página**, no al arrancar.
+`REQUEST_DELETE_PACKAGES` para el «desinstalar» del menú.
+
+## Compilar
+
+```sh
+./gradlew :launcher:assembleDebug # requiere JDK 21 y el SDK de Android
+```
+
+---
+
+Autoría: Sergio (gioser.net), en co-autoría con Claude (Anthropic).
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 0c30323..b940470 100644
--- a/launcher/src/main/java/com/rizoma/orbita/data/UsageStore.kt
+++ b/launcher/src/main/java/com/rizoma/orbita/data/UsageStore.kt
@@ -6,6 +6,7 @@ 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.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
@@ -80,6 +81,13 @@ class UsageStore(context: Context) {
}
}
+ /** Criterio de orden elegido en la búsqueda (se recuerda entre sesiones). */
+ fun sortMode(): Flow<String?> = store.data.map { it[SORT] }
+
+ suspend fun setSortMode(mode: String) {
+ store.edit { prefs -> prefs[SORT] = mode }
+ }
+
suspend fun record(nodeKey: String) {
store.edit { prefs ->
val k = intPreferencesKey(COUNT_PREFIX + nodeKey)
@@ -132,5 +140,6 @@ class UsageStore(context: Context) {
const val PUSH_BUFFER = 8
val PINNED = stringSetPreferencesKey("pinned")
val HIDDEN = stringSetPreferencesKey("hidden")
+ val SORT = stringPreferencesKey("search_sort")
}
}
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 67a266a..dbe38a0 100644
--- a/launcher/src/main/java/com/rizoma/orbita/model/AppNode.kt
+++ b/launcher/src/main/java/com/rizoma/orbita/model/AppNode.kt
@@ -8,6 +8,7 @@ import androidx.compose.ui.graphics.ImageBitmap
* [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.
+ * [penalty] es el lastre de "alejar": resta calor a la hora de repartir órbitas.
*/
data class AppNode(
val packageName: String,
@@ -16,6 +17,7 @@ data class AppNode(
val icon: ImageBitmap,
val score: Float = 0f,
val pinned: Boolean = false,
+ val penalty: Int = 0,
val hidden: Boolean = false,
val firstInstallTime: Long = 0L,
val hiddenAt: Long = 0L,
diff --git a/launcher/src/main/java/com/rizoma/orbita/model/SearchSort.kt b/launcher/src/main/java/com/rizoma/orbita/model/SearchSort.kt
new file mode 100644
index 0000000..0ff420a
--- /dev/null
+++ b/launcher/src/main/java/com/rizoma/orbita/model/SearchSort.kt
@@ -0,0 +1,18 @@
+package com.rizoma.orbita.model
+
+/** Criterio de orden de la búsqueda. El del rizoma —la frecuencia— es el de casa. */
+enum class SearchSort {
+ /** Calor de uso: lo más lanzado arriba (por defecto). */
+ FRECUENCIA,
+
+ /** Alfabético por etiqueta. */
+ NOMBRE,
+
+ /** Lo instalado hace menos tiempo, arriba. */
+ RECIENTE;
+
+ companion object {
+ fun from(name: String?): SearchSort =
+ entries.firstOrNull { it.name == name } ?: FRECUENCIA
+ }
+}
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 a9da2d4..f9dc0a3 100644
--- a/launcher/src/main/java/com/rizoma/orbita/ui/OrbitApp.kt
+++ b/launcher/src/main/java/com/rizoma/orbita/ui/OrbitApp.kt
@@ -21,6 +21,7 @@ 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.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
@@ -63,6 +64,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -71,6 +73,7 @@ import androidx.core.content.ContextCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import com.rizoma.orbita.R
import com.rizoma.orbita.model.AppNode
+import com.rizoma.orbita.model.SearchSort
import com.rizoma.orbita.ui.theme.NeonCian
import com.rizoma.orbita.ui.theme.NeonRosa
import com.rizoma.orbita.ui.theme.NeonVerde
@@ -94,6 +97,7 @@ fun OrbitApp(vm: OrbitaViewModel = viewModel()) {
val weather by vm.weather.collectAsState()
var searchOpen by remember { mutableStateOf(false) }
+ val searchSort by vm.searchSort.collectAsState()
var menuNode by remember { mutableStateOf<AppNode?>(null) }
val context = LocalContext.current
@@ -190,18 +194,24 @@ fun OrbitApp(vm: OrbitaViewModel = viewModel()) {
}
}
- PageDots(
- progress = { pos },
+ Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.windowInsetsPadding(WindowInsets.systemBars)
- .padding(bottom = 22.dp),
- )
+ .padding(bottom = 14.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ PageDots(progress = { pos })
+ Spacer(Modifier.height(10.dp))
+ Marca()
+ }
}
if (searchOpen) {
SearchOverlay(
nodes = allNodes,
+ sort = searchSort,
+ onSort = vm::setSearchSort,
onLaunch = {
vm.launch(it)
searchOpen = false
@@ -229,14 +239,25 @@ fun OrbitApp(vm: OrbitaViewModel = viewModel()) {
@Composable
private fun SearchOverlay(
nodes: List<AppNode>,
+ sort: SearchSort,
+ onSort: (SearchSort) -> Unit,
onLaunch: (AppNode) -> Unit,
onLongPress: (AppNode) -> Unit,
onClose: () -> Unit,
) {
var query by remember { mutableStateOf("") }
- val filtered = remember(query, nodes) {
- if (query.isBlank()) nodes
- else nodes.filter { it.label.contains(query.trim(), ignoreCase = true) }
+ val filtered = remember(query, nodes, sort) {
+ val hits =
+ if (query.isBlank()) nodes
+ else nodes.filter { it.label.contains(query.trim(), ignoreCase = true) }
+ when (sort) {
+ // `nodes` ya viene ordenado por calor (fijados primero): es el orden de casa.
+ SearchSort.FRECUENCIA -> hits
+ SearchSort.NOMBRE -> hits.sortedBy { it.label.lowercase() }
+ SearchSort.RECIENTE -> hits.sortedWith(
+ compareByDescending<AppNode> { it.firstInstallTime }.thenBy { it.label.lowercase() }
+ )
+ }
}
val focus = remember { FocusRequester() }
LaunchedEffect(Unit) { focus.requestFocus() }
@@ -255,8 +276,9 @@ private fun SearchOverlay(
Column(
Modifier
.fillMaxSize()
+ .windowInsetsPadding(WindowInsets.systemBars)
.padding(horizontal = 28.dp)
- .padding(top = 96.dp)
+ .padding(top = 64.dp)
) {
BasicTextField(
value = query,
@@ -280,9 +302,23 @@ private fun SearchOverlay(
)
Spacer(Modifier.height(4.dp))
Box(Modifier.fillMaxWidth().height(1.dp).background(NeonVerde.copy(alpha = 0.4f)))
- Spacer(Modifier.height(16.dp))
+ Spacer(Modifier.height(14.dp))
+
+ // Criterio de orden: frecuencia (el del rizoma), nombre o recién instaladas.
+ Row(Modifier.fillMaxWidth()) {
+ OrdenChip(R.string.sort_frequency, sort == SearchSort.FRECUENCIA, NeonVerde) {
+ onSort(SearchSort.FRECUENCIA)
+ }
+ OrdenChip(R.string.sort_name, sort == SearchSort.NOMBRE, NeonCian) {
+ onSort(SearchSort.NOMBRE)
+ }
+ OrdenChip(R.string.sort_recent, sort == SearchSort.RECIENTE, NeonRosa) {
+ onSort(SearchSort.RECIENTE)
+ }
+ }
+ Spacer(Modifier.height(12.dp))
- LazyColumn(Modifier.fillMaxSize()) {
+ LazyColumn(Modifier.fillMaxWidth().weight(1f)) {
items(filtered, key = { it.key }) { node ->
// Los expulsados se ven atenuados; un toque largo abre su menú (para devolverlos).
val dim = if (node.hidden) 0.4f else 1f
@@ -321,10 +357,43 @@ private fun SearchOverlay(
}
}
}
+ Marca(Modifier.align(Alignment.CenterHorizontally).padding(vertical = 14.dp))
}
}
}
+/** Un criterio de orden: encendido lleva su color; apagado, apenas se insinúa. */
+@Composable
+private fun RowScope.OrdenChip(labelRes: Int, selected: Boolean, accent: Color, onClick: () -> Unit) {
+ Text(
+ text = stringResource(labelRes),
+ color = if (selected) accent else Tenue.copy(alpha = 0.45f),
+ fontSize = 12.sp,
+ fontWeight = if (selected) FontWeight.Medium else FontWeight.Light,
+ maxLines = 1,
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .weight(1f)
+ .clip(RoundedCornerShape(20.dp))
+ .background(if (selected) accent.copy(alpha = 0.12f) else Color.Transparent)
+ .clickable(onClick = onClick)
+ .padding(vertical = 7.dp),
+ )
+}
+
+/** Marca de la casa: discreta, del tamaño de una firma. */
+@Composable
+internal fun Marca(modifier: Modifier = Modifier) {
+ Text(
+ text = stringResource(R.string.brand),
+ color = Color.White.copy(alpha = 0.22f),
+ fontSize = 9.sp,
+ letterSpacing = 3.sp,
+ fontWeight = FontWeight.Light,
+ modifier = modifier,
+ )
+}
+
@Composable
private fun NodeMenu(
node: AppNode,
diff --git a/launcher/src/main/java/com/rizoma/orbita/ui/OrbitLayout.kt b/launcher/src/main/java/com/rizoma/orbita/ui/OrbitLayout.kt
index 289a91f..45e4ce6 100644
--- a/launcher/src/main/java/com/rizoma/orbita/ui/OrbitLayout.kt
+++ b/launcher/src/main/java/com/rizoma/orbita/ui/OrbitLayout.kt
@@ -2,69 +2,87 @@ package com.rizoma.orbita.ui
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.Spring
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.spring
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.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.foundation.border
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
+import androidx.compose.runtime.State
+import androidx.compose.runtime.key
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.draw.scale
+import androidx.compose.ui.draw.drawBehind
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 androidx.compose.ui.graphics.drawscope.Stroke
+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.model.AppNode
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.NucleoPaleta
import com.rizoma.orbita.ui.theme.Tenue
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.min
-import kotlin.math.roundToInt
import kotlin.math.sin
-private data class Placement(
- val node: AppNode,
+/** La casilla orbital de un nodo: en qué anillo cae y con qué ángulo de partida. */
+private data class Slot(
val ring: Int,
- val angle: Double,
+ val angle: Float,
val radius: Float,
)
+/** El reparto entero: una casilla por clave de nodo, más cuántos anillos ocupa. */
+private data class Orbitas(val slots: Map<String, Slot>, val rings: Int)
+
private val ringColors = listOf(NeonVerde, NeonRosa, NeonCian)
+private const val TAU_F = (2 * PI).toFloat()
+
+/** Una vuelta completa del tinte del núcleo: tan larga que el cambio no se percibe. */
+private const val TINTE_MS = 900_000
/**
* Distribución anti-rejilla: los nodos caen en órbitas concéntricas según su calor.
* Las órbitas internas (más cerca del centro) son las apps más usadas y las fijadas.
+ *
+ * Dos reglas mantienen la ronda estable (nada de saltos):
+ * - el reparto en casillas se congela: el orden por calor decide la primera vez,
+ * y a partir de ahí un nodo conserva su casilla aunque su calor cambie al usarlo;
+ * - la posición se calcula en la fase de dibujo (graphicsLayer), no recomponiendo:
+ * la vuelta va a 60 fps sin depender de que Compose recomponga cada fotograma.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -77,12 +95,27 @@ fun OrbitLayout(
) {
val density = LocalDensity.current
val transition = rememberInfiniteTransition(label = "orbita")
- val spin by transition.animateFloat(
+ // Sin `by`: el State se lee dentro de lambdas de dibujo, así el giro no recompone.
+ val spin = transition.animateFloat(
initialValue = 0f,
- targetValue = (2 * PI).toFloat(),
+ targetValue = TAU_F,
animationSpec = infiniteRepeatable(tween(180_000, easing = LinearEasing), RepeatMode.Restart),
label = "spin",
)
+ val pulse = transition.animateFloat(
+ initialValue = 0.94f,
+ targetValue = 1.06f,
+ animationSpec = infiniteRepeatable(tween(5_200, easing = LinearEasing), RepeatMode.Reverse),
+ label = "pulso",
+ )
+ // Deriva de color del núcleo: recorre la paleta entera en 15 minutos.
+ val tinte = transition.animateFloat(
+ initialValue = 0f,
+ targetValue = NucleoPaleta.size.toFloat(),
+ animationSpec = infiniteRepeatable(tween(TINTE_MS, easing = LinearEasing), RepeatMode.Restart),
+ label = "tinte",
+ )
+ val glow: () -> Color = { colorDelCiclo(tinte.value) }
BoxWithConstraints(modifier.fillMaxSize()) {
val wPx = with(density) { maxWidth.toPx() }
@@ -96,25 +129,32 @@ fun OrbitLayout(
val colHalf = with(density) { 32.dp.toPx() }
val iconHalf = with(density) { 27.dp.toPx() }
- val placements = remember(nodes, wPx, hPx) {
- computePlacements(nodes, firstR, ringGap, spacing)
- }
- val rings = (placements.maxOfOrNull { it.ring } ?: -1) + 1
+ val orbitas = rememberOrbitas(nodes, firstR, ringGap, spacing)
+ val rings = orbitas.rings
- // Halo ambiental + anillos orbitales.
+ // Halo del núcleo (mucho alcance, muy difuminado) + anillos orbitales.
Canvas(Modifier.fillMaxSize()) {
+ val c = glow()
+ val p = pulse.value
+ val halo = min(wPx, hPx) * 0.58f * p
drawCircle(
brush = Brush.radialGradient(
- colors = listOf(NeonVerde.copy(alpha = 0.06f), androidx.compose.ui.graphics.Color.Transparent),
+ 0.00f to c.copy(alpha = 0.22f),
+ 0.16f to c.copy(alpha = 0.115f),
+ 0.38f to c.copy(alpha = 0.055f),
+ 0.66f to c.copy(alpha = 0.018f),
+ 1.00f to Color.Transparent,
center = Offset(cx, cy),
- radius = maxOf(wPx, hPx) * 0.6f,
+ radius = halo,
),
- radius = maxOf(wPx, hPx) * 0.6f,
+ radius = halo,
center = Offset(cx, cy),
+ blendMode = BlendMode.Plus,
)
for (r in 0 until rings) {
+ // Los anillos se tiñen a medias con el color vivo del núcleo.
drawCircle(
- color = ringColors[r % ringColors.size].copy(alpha = 0.12f),
+ color = lerp(ringColors[r % ringColors.size], c, 0.5f).copy(alpha = 0.14f),
radius = firstR + r * ringGap,
center = Offset(cx, cy),
style = Stroke(width = 1.2f),
@@ -125,26 +165,93 @@ fun OrbitLayout(
// Núcleo: toque para buscar en el rizoma.
CenterHub(
modifier = Modifier.align(Alignment.Center),
+ glow = glow,
+ pulse = pulse,
onTap = onCenterTap,
)
- // Nodos en órbita.
- placements.forEach { p ->
- // Anillos contiguos contrarrotan. Factor entero (±1) → el bucle es continuo.
- val dir = if (p.ring % 2 == 0) 1f else -1f
- val angle = p.angle + spin * dir
- val x = cx + p.radius * cos(angle).toFloat() - colHalf
- val y = cy + p.radius * sin(angle).toFloat() - iconHalf
- NodeItem(
- node = p.node,
- modifier = Modifier.offset { IntOffset(x.roundToInt(), y.roundToInt()) },
- onLaunch = onLaunch,
- onLongPress = onLongPress,
- )
+ // Nodos en órbita. Si una casilla cambia (se instala o se expulsa una app),
+ // el icono viaja hasta ella con un muelle lento: nunca reaparece en otro sitio.
+ nodes.forEach { node ->
+ val slot = orbitas.slots[node.key] ?: return@forEach
+ key(node.key) {
+ val angle = animateFloatAsState(
+ targetValue = slot.angle,
+ animationSpec = spring(dampingRatio = 1f, stiffness = Spring.StiffnessVeryLow),
+ label = "ang",
+ )
+ val radius = animateFloatAsState(
+ targetValue = slot.radius,
+ animationSpec = spring(dampingRatio = 1f, stiffness = Spring.StiffnessVeryLow),
+ label = "rad",
+ )
+ // Anillos contiguos contrarrotan. Factor entero (±1) → el bucle es continuo.
+ val dir = if (slot.ring % 2 == 0) 1f else -1f
+ NodeItem(
+ node = node,
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .graphicsLayer {
+ val a = angle.value + spin.value * dir
+ translationX = cx + radius.value * cos(a) - colHalf
+ translationY = cy + radius.value * sin(a) - iconHalf
+ },
+ onLaunch = onLaunch,
+ onLongPress = onLongPress,
+ )
+ }
}
}
}
+/**
+ * Casillas estables. El orden solo se recalcula cuando cambia la *firma* del átomo:
+ * qué apps hay, cuáles están fijadas y cuáles alejadas. El calor sube al usar una app,
+ * pero no mueve nada por su cuenta — el átomo se reordena cuando tú actúas sobre él,
+ * nunca a tus espaldas mientras miras la ronda. Y aun entonces, cada icono viaja a su
+ * nueva casilla con un muelle lento: nunca reaparece de golpe en otro sitio.
+ */
+@Composable
+private fun rememberOrbitas(
+ nodes: List<AppNode>,
+ firstR: Float,
+ ringGap: Float,
+ spacing: Float,
+): Orbitas {
+ val anguloPrevio = remember { HashMap<String, Float>() }
+ val firma = nodes.joinToString("|") { "${it.key}#${if (it.pinned) 1 else 0}#${it.penalty}" }
+ return remember(firma, firstR, ringGap, spacing) {
+ val slots = HashMap<String, Slot>(nodes.size)
+ var rings = 0
+ computePlacements(nodes.map { it.key }, firstR, ringGap, spacing).forEach { (clave, slot) ->
+ // El ángulo se "desenrolla" respecto al anterior para que la animación
+ // tome el camino corto y no dé una vuelta entera de más.
+ val previo = anguloPrevio[clave]
+ val angulo = if (previo == null) slot.angle else desenrollar(previo, slot.angle)
+ anguloPrevio[clave] = angulo
+ slots[clave] = slot.copy(angle = angulo)
+ rings = max(rings, slot.ring + 1)
+ }
+ anguloPrevio.keys.retainAll(slots.keys)
+ Orbitas(slots, rings)
+ }
+}
+
+private fun desenrollar(previo: Float, destino: Float): Float {
+ var a = destino
+ while (a - previo > TAU_F / 2f) a -= TAU_F
+ while (previo - a > TAU_F / 2f) a += TAU_F
+ return a
+}
+
+/** Color continuo a lo largo de la paleta del núcleo (t recorre 0..paleta.size). */
+private fun colorDelCiclo(t: Float): Color {
+ val n = NucleoPaleta.size
+ val base = floor(t).toInt()
+ val f = t - base
+ return lerp(NucleoPaleta[((base % n) + n) % n], NucleoPaleta[((base + 1) % n + n) % n], f)
+}
+
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun NodeItem(
@@ -183,25 +290,39 @@ private fun NodeItem(
}
}
+/**
+ * El corazón del átomo: un núcleo blanco muy brillante envuelto en el color del ciclo.
+ * El halo amplio lo pinta el lienzo de fondo; aquí solo va el punto vivo y su toque.
+ */
@Composable
-private fun CenterHub(modifier: Modifier, onTap: () -> Unit) {
- val transition = rememberInfiniteTransition(label = "hub")
- val pulse by transition.animateFloat(
- initialValue = 0.88f,
- targetValue = 1.08f,
- animationSpec = infiniteRepeatable(tween(2600, easing = LinearEasing), RepeatMode.Reverse),
- label = "pulse",
- )
+private fun CenterHub(
+ modifier: Modifier,
+ glow: () -> Color,
+ pulse: State<Float>,
+ onTap: () -> Unit,
+) {
Column(
modifier = modifier
- .size(76.dp)
- .scale(pulse)
+ .size(96.dp)
.clip(CircleShape)
- .background(
- Brush.radialGradient(
- listOf(NeonVerde.copy(alpha = 0.22f), androidx.compose.ui.graphics.Color.Transparent)
+ .drawBehind {
+ val c = glow()
+ val r = size.minDimension / 2f * pulse.value
+ drawCircle(
+ brush = Brush.radialGradient(
+ 0.00f to Color.White.copy(alpha = 0.42f),
+ 0.10f to c.copy(alpha = 0.80f),
+ 0.35f to c.copy(alpha = 0.34f),
+ 0.68f to c.copy(alpha = 0.10f),
+ 1.00f to Color.Transparent,
+ center = center,
+ radius = r,
+ ),
+ radius = r,
+ center = center,
+ blendMode = BlendMode.Plus,
)
- )
+ }
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
@@ -211,37 +332,37 @@ private fun CenterHub(modifier: Modifier, onTap: () -> Unit) {
verticalArrangement = Arrangement.Center,
) {
Text(
- text = "✦",
- color = NeonVerde,
- fontSize = 26.sp,
+ text = "\u2726",
+ color = Color.White.copy(alpha = 0.92f),
+ fontSize = 24.sp,
fontWeight = FontWeight.Light,
)
}
}
/**
- * Reparte los nodos (ya ordenados) en anillos. Cada anillo crece hacia afuera y su
+ * Reparte las claves (ya ordenadas) en anillos. Cada anillo crece hacia afuera y su
* capacidad depende de su circunferencia, así nada se amontona.
*/
private fun computePlacements(
- nodes: List<AppNode>,
+ keys: List<String>,
firstR: Float,
ringGap: Float,
spacing: Float,
-): List<Placement> {
- if (nodes.isEmpty()) return emptyList()
- val result = ArrayList<Placement>(nodes.size)
+): List<Pair<String, Slot>> {
+ if (keys.isEmpty()) return emptyList()
+ val result = ArrayList<Pair<String, Slot>>(keys.size)
var index = 0
var ring = 0
- while (index < nodes.size) {
+ while (index < keys.size) {
val radius = firstR + ring * ringGap
val circumference = 2.0 * PI * radius
val capacity = max(4, floor(circumference / spacing).toInt())
- val count = min(capacity, nodes.size - index)
- val base = ring * 0.5
+ val count = min(capacity, keys.size - index)
+ val base = ring * 0.5f
for (j in 0 until count) {
- val angle = base + 2.0 * PI * j / count
- result.add(Placement(nodes[index + j], ring, angle, radius))
+ val angle = base + TAU_F * j / count
+ result.add(keys[index + j] to Slot(ring, angle, radius))
}
index += count
ring++
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 a371fc7..8aa1549 100644
--- a/launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt
+++ b/launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt
@@ -17,6 +17,7 @@ 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 com.rizoma.orbita.model.SearchSort
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -43,6 +44,7 @@ class OrbitaViewModel(app: Application) : AndroidViewModel(app) {
node.copy(
score = (u.counts[node.key] ?: 0).toFloat(),
pinned = node.key in u.pinned,
+ penalty = u.penalties[node.key] ?: 0,
hidden = node.key in u.hidden,
hiddenAt = u.hiddenAt[node.key] ?: 0L,
)
@@ -81,6 +83,17 @@ class OrbitaViewModel(app: Application) : AndroidViewModel(app) {
return Buckets(atom, nuevas, expulsadas, desterradas.sortedBy { it.label.lowercase() })
}
+ // --- Búsqueda: criterio de orden (se recuerda entre sesiones). ---
+
+ /** Cómo se ordena la lista de la búsqueda. Por defecto, por frecuencia de uso. */
+ val searchSort: StateFlow<SearchSort> = usage.sortMode()
+ .map { SearchSort.from(it) }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SearchSort.FRECUENCIA)
+
+ fun setSearchSort(sort: SearchSort) {
+ viewModelScope.launch { usage.setSortMode(sort.name) }
+ }
+
// --- Cielo del fondo: hora del día + clima local (perezoso). ---
private val _weather = MutableStateFlow<Weather?>(null)
diff --git a/launcher/src/main/java/com/rizoma/orbita/ui/Pages.kt b/launcher/src/main/java/com/rizoma/orbita/ui/Pages.kt
index fc08fe6..d07f918 100644
--- a/launcher/src/main/java/com/rizoma/orbita/ui/Pages.kt
+++ b/launcher/src/main/java/com/rizoma/orbita/ui/Pages.kt
@@ -280,7 +280,7 @@ private fun BoxWithConstraintsScope.ZoneEmpty(text: String, align: Alignment) {
/** Cinco puntos: el más cercano a la página actual brilla. Lee [progress] al dibujar. */
@Composable
-fun PageDots(progress: () -> Float, modifier: Modifier) {
+fun PageDots(progress: () -> Float, modifier: Modifier = Modifier) {
Canvas(modifier.fillMaxWidth().height(26.dp)) {
val p = progress()
val n = 5
diff --git a/launcher/src/main/java/com/rizoma/orbita/ui/theme/Theme.kt b/launcher/src/main/java/com/rizoma/orbita/ui/theme/Theme.kt
index 9dbe742..35898c5 100644
--- a/launcher/src/main/java/com/rizoma/orbita/ui/theme/Theme.kt
+++ b/launcher/src/main/java/com/rizoma/orbita/ui/theme/Theme.kt
@@ -12,6 +12,15 @@ val NeonVerde = Color(0xFF7CFF4F)
val NeonRosa = Color(0xFFFF2E88)
val NeonCian = Color(0xFF00E5FF)
val Tenue = Color(0xB3FFFFFF)
+val NeonVioleta = Color(0xFF9A6BFF)
+val NeonAmbar = Color(0xFFFFC15E)
+
+/**
+ * La deriva del núcleo: el centro del átomo recorre esta paleta en bucle, tan
+ * despacio (una vuelta completa cada 15 min) que el cambio no se percibe —
+ * solo se nota que el color de hace un rato ya no es el de ahora.
+ */
+val NucleoPaleta = listOf(NeonVerde, NeonCian, NeonVioleta, NeonRosa, NeonAmbar)
private val EsquemaOrbita = darkColorScheme(
primary = NeonVerde,
diff --git a/launcher/src/main/res/values/strings.xml b/launcher/src/main/res/values/strings.xml
index eda3875..3edbcc0 100644
--- a/launcher/src/main/res/values/strings.xml
+++ b/launcher/src/main/res/values/strings.xml
@@ -10,4 +10,8 @@
<string name="menu_info">info</string>
<string name="menu_uninstall">desinstalar</string>
<string name="tag_hidden">fuera del átomo</string>
+ <string name="sort_frequency">frecuencia</string>
+ <string name="sort_name">nombre</string>
+ <string name="sort_recent">recientes</string>
+ <string name="brand">gioser.net</string>
</resources>