gioser

sergio/humanoid

HTML 13TOML 10 Sutichaykuna

Órbita: launcher orbital anti-rejilla (v0.1)

Primera versión funcional del launcher del rizoma:
- Carga de apps lanzables vía PackageManager con iconos rasterizados.
- Disposición orbital: nodos en órbitas concéntricas según su calor (uso);
  fijados y más usados caen a órbitas internas. Anillos contrarrotantes.
- Toque para lanzar, mantener para menú (fijar/soltar, info, quitar).
- Núcleo central pulsante → búsqueda en vivo sobre todo el rizoma.
- Persistencia de uso y fijados con DataStore; refresco ante instalar/desinstalar.

Build: añade material3 y lifecycle-viewmodel-compose; :keyboard pospuesto.

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

3feee86e320e76ed9ddd32f09ed18d20260c4e2a · ruwaq sergio · (4 killa ñawpaq) · kay commitpi sach'ata qhaway

Semántico sayk'uy

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

21 qillqakuna · +964 −0

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1d22aea
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,13 @@
+*.iml
+.gradle/
+/local.properties
+.idea/
+.DS_Store
+build/
+captures/
+.externalNativeBuild/
+.cxx/
+*.apk
+*.aab
+*.keystore
+!debug.keystore
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..9deb573
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,5 @@
+plugins {
+    alias(libs.plugins.android.application) apply false
+    alias(libs.plugins.kotlin.android) apply false
+    alias(libs.plugins.kotlin.compose) apply false
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..ea3d684
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,7 @@
+org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
+org.gradle.java.home=/home/sergio/jdks/jdk-21.0.11+10
+org.gradle.parallel=true
+org.gradle.caching=true
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+kotlin.code.style=official
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..91d1ef1
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,29 @@
+[versions]
+agp = "8.11.1"
+kotlin = "2.2.0"
+coreKtx = "1.16.0"
+lifecycle = "2.9.1"
+activityCompose = "1.10.1"
+composeBom = "2025.06.01"
+datastore = "1.1.7"
+coroutines = "1.10.2"
+
+[libraries]
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
+androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
+androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
+androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+androidx-compose-ui-util = { group = "androidx.compose.ui", name = "ui-util" }
+androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
+androidx-compose-animation = { group = "androidx.compose.animation", name = "animation" }
+androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
+androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
+kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
diff --git a/launcher/build.gradle.kts b/launcher/build.gradle.kts
new file mode 100644
index 0000000..3e04208
--- /dev/null
+++ b/launcher/build.gradle.kts
@@ -0,0 +1,51 @@
+plugins {
+    alias(libs.plugins.android.application)
+    alias(libs.plugins.kotlin.android)
+    alias(libs.plugins.kotlin.compose)
+}
+
+android {
+    namespace = "com.rizoma.orbita"
+    compileSdk = 36
+
+    defaultConfig {
+        applicationId = "com.rizoma.orbita"
+        minSdk = 26
+        targetSdk = 36
+        versionCode = 1
+        versionName = "0.1.0"
+    }
+
+    buildTypes {
+        release {
+            isMinifyEnabled = true
+            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+        }
+    }
+    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/launcher/proguard-rules.pro b/launcher/proguard-rules.pro
new file mode 100644
index 0000000..543a505
--- /dev/null
+++ b/launcher/proguard-rules.pro
@@ -0,0 +1 @@
+# Reglas específicas del launcher (vacío por ahora)
diff --git a/launcher/src/main/AndroidManifest.xml b/launcher/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..8df0925
--- /dev/null
+++ b/launcher/src/main/AndroidManifest.xml
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <!-- Necesario para listar todas las apps lanzables en Android 11+ -->
+    <queries>
+        <intent>
+            <action android:name="android.intent.action.MAIN" />
+            <category android:name="android.intent.category.LAUNCHER" />
+        </intent>
+    </queries>
+
+    <uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
+
+    <application
+        android:label="@string/app_name"
+        android:icon="@mipmap/ic_launcher"
+        android:theme="@style/Theme.Orbita"
+        android:supportsRtl="true">
+
+        <activity
+            android:name=".MainActivity"
+            android:exported="true"
+            android:launchMode="singleTask"
+            android:stateNotNeeded="true"
+            android:excludeFromRecents="true"
+            android:screenOrientation="portrait"
+            android:configChanges="orientation|screenSize|keyboard|keyboardHidden|uiMode">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.HOME" />
+                <category android:name="android.intent.category.DEFAULT" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+    </application>
+</manifest>
diff --git a/launcher/src/main/java/com/rizoma/orbita/MainActivity.kt b/launcher/src/main/java/com/rizoma/orbita/MainActivity.kt
new file mode 100644
index 0000000..323ecd1
--- /dev/null
+++ b/launcher/src/main/java/com/rizoma/orbita/MainActivity.kt
@@ -0,0 +1,20 @@
+package com.rizoma.orbita
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.core.view.WindowCompat
+import com.rizoma.orbita.ui.OrbitApp
+import com.rizoma.orbita.ui.theme.OrbitaTheme
+
+class MainActivity : ComponentActivity() {
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        WindowCompat.setDecorFitsSystemWindows(window, false)
+        setContent {
+            OrbitaTheme {
+                OrbitApp()
+            }
+        }
+    }
+}
diff --git a/launcher/src/main/java/com/rizoma/orbita/data/AppRepository.kt b/launcher/src/main/java/com/rizoma/orbita/data/AppRepository.kt
new file mode 100644
index 0000000..83bc016
--- /dev/null
+++ b/launcher/src/main/java/com/rizoma/orbita/data/AppRepository.kt
@@ -0,0 +1,39 @@
+package com.rizoma.orbita.data
+
+import android.content.Context
+import android.content.Intent
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.core.graphics.drawable.toBitmap
+import com.rizoma.orbita.model.AppNode
+
+/**
+ * Cosecha del sistema: todas las actividades lanzables se vuelven nodos del rizoma.
+ * El icono se rasteriza una sola vez aquí (operación cara → fuera del hilo principal).
+ */
+class AppRepository(context: Context) {
+
+    private val appContext = context.applicationContext
+    private val pm = appContext.packageManager
+
+    fun loadApps(): List<AppNode> {
+        val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
+        val self = appContext.packageName
+        return pm.queryIntentActivities(intent, 0)
+            .asSequence()
+            .filter { it.activityInfo.packageName != self }
+            .map { ri ->
+                val icon = ri.loadIcon(pm).toBitmap(ICON_PX, ICON_PX).asImageBitmap()
+                AppNode(
+                    packageName = ri.activityInfo.packageName,
+                    activityName = ri.activityInfo.name,
+                    label = ri.loadLabel(pm).toString(),
+                    icon = icon,
+                )
+            }
+            .toList()
+    }
+
+    private companion object {
+        const val ICON_PX = 144
+    }
+}
diff --git a/launcher/src/main/java/com/rizoma/orbita/data/UsageStore.kt b/launcher/src/main/java/com/rizoma/orbita/data/UsageStore.kt
new file mode 100644
index 0000000..5fd4fbb
--- /dev/null
+++ b/launcher/src/main/java/com/rizoma/orbita/data/UsageStore.kt
@@ -0,0 +1,54 @@
+package com.rizoma.orbita.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.intPreferencesKey
+import androidx.datastore.preferences.core.stringSetPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+private val Context.orbitaStore: DataStore<Preferences> by preferencesDataStore(name = "orbita_usage")
+
+/**
+ * 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.
+ */
+class UsageStore(context: Context) {
+
+    private val store = context.applicationContext.orbitaStore
+
+    fun counts(): Flow<Map<String, Int>> = store.data.map { prefs ->
+        buildMap {
+            prefs.asMap().forEach { (key, value) ->
+                if (key.name.startsWith(COUNT_PREFIX) && value is Int) {
+                    put(key.name.removePrefix(COUNT_PREFIX), value)
+                }
+            }
+        }
+    }
+
+    fun pinned(): Flow<Set<String>> = store.data.map { it[PINNED] ?: emptySet() }
+
+    suspend fun record(nodeKey: String) {
+        store.edit { prefs ->
+            val k = intPreferencesKey(COUNT_PREFIX + nodeKey)
+            prefs[k] = (prefs[k] ?: 0) + 1
+        }
+    }
+
+    suspend fun setPinned(nodeKey: String, pinned: Boolean) {
+        store.edit { prefs ->
+            val current = prefs[PINNED]?.toMutableSet() ?: mutableSetOf()
+            if (pinned) current.add(nodeKey) else current.remove(nodeKey)
+            prefs[PINNED] = current
+        }
+    }
+
+    private companion object {
+        const val COUNT_PREFIX = "c:"
+        val PINNED = stringSetPreferencesKey("pinned")
+    }
+}
diff --git a/launcher/src/main/java/com/rizoma/orbita/model/AppNode.kt b/launcher/src/main/java/com/rizoma/orbita/model/AppNode.kt
new file mode 100644
index 0000000..b75b461
--- /dev/null
+++ b/launcher/src/main/java/com/rizoma/orbita/model/AppNode.kt
@@ -0,0 +1,18 @@
+package com.rizoma.orbita.model
+
+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.
+ */
+data class AppNode(
+    val packageName: String,
+    val activityName: String,
+    val label: String,
+    val icon: ImageBitmap,
+    val score: Float = 0f,
+    val pinned: Boolean = false,
+) {
+    val key: String get() = "$packageName/$activityName"
+}
diff --git a/launcher/src/main/java/com/rizoma/orbita/ui/OrbitApp.kt b/launcher/src/main/java/com/rizoma/orbita/ui/OrbitApp.kt
new file mode 100644
index 0000000..0dfb859
--- /dev/null
+++ b/launcher/src/main/java/com/rizoma/orbita/ui/OrbitApp.kt
@@ -0,0 +1,230 @@
+package com.rizoma.orbita.ui
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+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.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.width
+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.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+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.SolidColor
+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.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Dialog
+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
+import com.rizoma.orbita.ui.theme.NeonRosa
+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
+
+@Composable
+fun OrbitApp(vm: OrbitaViewModel = viewModel()) {
+    val nodes by vm.nodes.collectAsState()
+    var searchOpen by remember { mutableStateOf(false) }
+    var menuNode by remember { mutableStateOf<AppNode?>(null) }
+
+    // Como launcher: "atrás" cierra la búsqueda o no hace nada (nunca sale al sistema).
+    BackHandler(enabled = true) {
+        if (searchOpen) searchOpen = false
+    }
+
+    Box(Modifier.fillMaxSize().background(Vacio)) {
+        OrbitLayout(
+            nodes = nodes,
+            onLaunch = vm::launch,
+            onLongPress = { menuNode = it },
+            onCenterTap = { searchOpen = true },
+        )
+
+        if (searchOpen) {
+            SearchOverlay(
+                nodes = nodes,
+                onLaunch = {
+                    vm.launch(it)
+                    searchOpen = false
+                },
+                onClose = { searchOpen = false },
+            )
+        }
+
+        menuNode?.let { node ->
+            NodeMenu(
+                node = node,
+                onPin = { vm.togglePin(node); menuNode = null },
+                onInfo = { vm.openInfo(node); menuNode = null },
+                onUninstall = { vm.uninstall(node); menuNode = null },
+                onDismiss = { menuNode = null },
+            )
+        }
+    }
+}
+
+@Composable
+private fun SearchOverlay(
+    nodes: List<AppNode>,
+    onLaunch: (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 focus = remember { FocusRequester() }
+    LaunchedEffect(Unit) { focus.requestFocus() }
+
+    Box(
+        Modifier
+            .fillMaxSize()
+            .background(Vacio.copy(alpha = 0.97f))
+            // Tocar el fondo cierra; el contenido absorbe sus propios toques.
+            .clickable(
+                interactionSource = remember { MutableInteractionSource() },
+                indication = null,
+                onClick = onClose,
+            )
+    ) {
+        Column(
+            Modifier
+                .fillMaxSize()
+                .padding(horizontal = 28.dp)
+                .padding(top = 96.dp)
+        ) {
+            BasicTextField(
+                value = query,
+                onValueChange = { query = it },
+                singleLine = true,
+                textStyle = TextStyle(color = NeonVerde, fontSize = 26.sp, fontWeight = FontWeight.Light),
+                cursorBrush = SolidColor(NeonRosa),
+                keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(imeAction = ImeAction.Search),
+                modifier = Modifier.fillMaxWidth().focusRequester(focus),
+                decorationBox = { inner ->
+                    if (query.isEmpty()) {
+                        Text(
+                            text = stringResource(R.string.search_hint),
+                            color = Tenue,
+                            fontSize = 26.sp,
+                            fontWeight = FontWeight.Light,
+                        )
+                    }
+                    inner()
+                },
+            )
+            Spacer(Modifier.height(4.dp))
+            Box(Modifier.fillMaxWidth().height(1.dp).background(NeonVerde.copy(alpha = 0.4f)))
+            Spacer(Modifier.height(16.dp))
+
+            LazyColumn(Modifier.fillMaxSize()) {
+                items(filtered, key = { it.key }) { node ->
+                    Row(
+                        Modifier
+                            .fillMaxWidth()
+                            .clickable { onLaunch(node) }
+                            .padding(vertical = 8.dp),
+                        verticalAlignment = Alignment.CenterVertically,
+                    ) {
+                        Image(
+                            bitmap = node.icon,
+                            contentDescription = node.label,
+                            modifier = Modifier.size(40.dp).clip(CircleShape),
+                        )
+                        Spacer(Modifier.width(16.dp))
+                        Text(
+                            text = node.label,
+                            color = androidx.compose.ui.graphics.Color.White,
+                            fontSize = 16.sp,
+                            maxLines = 1,
+                            overflow = TextOverflow.Ellipsis,
+                        )
+                    }
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun NodeMenu(
+    node: AppNode,
+    onPin: () -> Unit,
+    onInfo: () -> Unit,
+    onUninstall: () -> Unit,
+    onDismiss: () -> Unit,
+) {
+    Dialog(onDismissRequest = onDismiss) {
+        Surface(
+            color = Superficie,
+            shape = RoundedCornerShape(20.dp),
+        ) {
+            Column(Modifier.width(240.dp).padding(vertical = 10.dp)) {
+                Text(
+                    text = node.label,
+                    color = androidx.compose.ui.graphics.Color.White,
+                    fontSize = 15.sp,
+                    fontWeight = FontWeight.Medium,
+                    maxLines = 1,
+                    overflow = TextOverflow.Ellipsis,
+                    modifier = Modifier.padding(horizontal = 20.dp, vertical = 10.dp),
+                )
+                MenuRow(
+                    label = stringResource(if (node.pinned) R.string.menu_unpin else R.string.menu_pin),
+                    accent = NeonRosa,
+                    onClick = onPin,
+                )
+                MenuRow(label = stringResource(R.string.menu_info), accent = NeonCian, onClick = onInfo)
+                MenuRow(label = stringResource(R.string.menu_uninstall), accent = NeonRosa, onClick = onUninstall)
+            }
+        }
+    }
+}
+
+@Composable
+private fun MenuRow(label: String, accent: androidx.compose.ui.graphics.Color, onClick: () -> Unit) {
+    Row(
+        Modifier
+            .fillMaxWidth()
+            .clickable(onClick = onClick)
+            .padding(horizontal = 20.dp, vertical = 14.dp),
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        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)
+    }
+}
diff --git a/launcher/src/main/java/com/rizoma/orbita/ui/OrbitLayout.kt b/launcher/src/main/java/com/rizoma/orbita/ui/OrbitLayout.kt
new file mode 100644
index 0000000..b241a8a
--- /dev/null
+++ b/launcher/src/main/java/com/rizoma/orbita/ui/OrbitLayout.kt
@@ -0,0 +1,249 @@
+package com.rizoma.orbita.ui
+
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.RepeatMode
+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.background
+import androidx.compose.foundation.combinedClickable
+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.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.geometry.Offset
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.drawscope.Stroke
+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.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,
+    val ring: Int,
+    val angle: Double,
+    val radius: Float,
+)
+
+private val ringColors = listOf(NeonVerde, NeonRosa, NeonCian)
+
+/**
+ * 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.
+ */
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun OrbitLayout(
+    nodes: List<AppNode>,
+    onLaunch: (AppNode) -> Unit,
+    onLongPress: (AppNode) -> Unit,
+    onCenterTap: () -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    val density = LocalDensity.current
+    val transition = rememberInfiniteTransition(label = "orbita")
+    val spin by transition.animateFloat(
+        initialValue = 0f,
+        targetValue = (2 * PI).toFloat(),
+        animationSpec = infiniteRepeatable(tween(180_000, easing = LinearEasing), RepeatMode.Restart),
+        label = "spin",
+    )
+
+    BoxWithConstraints(modifier.fillMaxSize()) {
+        val wPx = with(density) { maxWidth.toPx() }
+        val hPx = with(density) { maxHeight.toPx() }
+        val cx = wPx / 2f
+        val cy = hPx / 2f
+
+        val firstR = with(density) { 116.dp.toPx() }
+        val ringGap = with(density) { 86.dp.toPx() }
+        val spacing = with(density) { 76.dp.toPx() }
+        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
+
+        // Halo ambiental + anillos orbitales.
+        Canvas(Modifier.fillMaxSize()) {
+            drawCircle(
+                brush = Brush.radialGradient(
+                    colors = listOf(NeonVerde.copy(alpha = 0.06f), androidx.compose.ui.graphics.Color.Transparent),
+                    center = Offset(cx, cy),
+                    radius = maxOf(wPx, hPx) * 0.6f,
+                ),
+                radius = maxOf(wPx, hPx) * 0.6f,
+                center = Offset(cx, cy),
+            )
+            for (r in 0 until rings) {
+                drawCircle(
+                    color = ringColors[r % ringColors.size].copy(alpha = 0.12f),
+                    radius = firstR + r * ringGap,
+                    center = Offset(cx, cy),
+                    style = Stroke(width = 1.2f),
+                )
+            }
+        }
+
+        // Núcleo: toque para buscar en el rizoma.
+        CenterHub(
+            modifier = Modifier.align(Alignment.Center),
+            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,
+            )
+        }
+    }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun NodeItem(
+    node: AppNode,
+    modifier: Modifier,
+    onLaunch: (AppNode) -> Unit,
+    onLongPress: (AppNode) -> Unit,
+) {
+    Column(
+        modifier = modifier.width(64.dp),
+        horizontalAlignment = Alignment.CenterHorizontally,
+    ) {
+        val iconMod = Modifier
+            .size(54.dp)
+            .clip(CircleShape)
+            .combinedClickable(
+                onClick = { onLaunch(node) },
+                onLongClick = { onLongPress(node) },
+            )
+            .then(
+                if (node.pinned) Modifier.border(1.5.dp, NeonRosa, CircleShape) else Modifier
+            )
+        Image(
+            bitmap = node.icon,
+            contentDescription = node.label,
+            modifier = iconMod,
+        )
+        Text(
+            text = node.label,
+            color = Tenue,
+            fontSize = 10.sp,
+            maxLines = 1,
+            overflow = TextOverflow.Ellipsis,
+            textAlign = TextAlign.Center,
+        )
+    }
+}
+
+@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",
+    )
+    Column(
+        modifier = modifier
+            .size(76.dp)
+            .scale(pulse)
+            .clip(CircleShape)
+            .background(
+                Brush.radialGradient(
+                    listOf(NeonVerde.copy(alpha = 0.22f), androidx.compose.ui.graphics.Color.Transparent)
+                )
+            )
+            .clickable(
+                interactionSource = remember { MutableInteractionSource() },
+                indication = null,
+                onClick = onTap,
+            ),
+        horizontalAlignment = Alignment.CenterHorizontally,
+        verticalArrangement = Arrangement.Center,
+    ) {
+        Text(
+            text = "✦",
+            color = NeonVerde,
+            fontSize = 26.sp,
+            fontWeight = FontWeight.Light,
+        )
+    }
+}
+
+/**
+ * Reparte los nodos (ya ordenados) en anillos. Cada anillo crece hacia afuera y su
+ * capacidad depende de su circunferencia, así nada se amontona.
+ */
+private fun computePlacements(
+    nodes: List<AppNode>,
+    firstR: Float,
+    ringGap: Float,
+    spacing: Float,
+): List<Placement> {
+    if (nodes.isEmpty()) return emptyList()
+    val result = ArrayList<Placement>(nodes.size)
+    var index = 0
+    var ring = 0
+    while (index < nodes.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
+        for (j in 0 until count) {
+            val angle = base + 2.0 * PI * j / count
+            result.add(Placement(nodes[index + j], ring, angle, radius))
+        }
+        index += count
+        ring++
+    }
+    return result
+}
diff --git a/launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt b/launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt
new file mode 100644
index 0000000..36e2a45
--- /dev/null
+++ b/launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt
@@ -0,0 +1,101 @@
+package com.rizoma.orbita.ui
+
+import android.app.Application
+import android.content.BroadcastReceiver
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.net.Uri
+import android.provider.Settings
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import com.rizoma.orbita.data.AppRepository
+import com.rizoma.orbita.data.UsageStore
+import com.rizoma.orbita.model.AppNode
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+
+class OrbitaViewModel(app: Application) : AndroidViewModel(app) {
+
+    private val repo = AppRepository(app)
+    private val usage = UsageStore(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(),
+    ) { apps, counts, pinned ->
+        apps.map { node ->
+            node.copy(
+                score = (counts[node.key] ?: 0).toFloat(),
+                pinned = node.key in pinned,
+            )
+        }.sortedWith(
+            compareByDescending<AppNode> { it.pinned }
+                .thenByDescending { it.score }
+                .thenBy { it.label.lowercase() }
+        )
+    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
+
+    private val pkgReceiver = object : BroadcastReceiver() {
+        override fun onReceive(context: Context?, intent: Intent?) = refresh()
+    }
+
+    init {
+        refresh()
+        val filter = IntentFilter().apply {
+            addAction(Intent.ACTION_PACKAGE_ADDED)
+            addAction(Intent.ACTION_PACKAGE_REMOVED)
+            addAction(Intent.ACTION_PACKAGE_CHANGED)
+            addDataScheme("package")
+        }
+        getApplication<Application>().registerReceiver(pkgReceiver, filter)
+    }
+
+    fun refresh() {
+        viewModelScope.launch(Dispatchers.IO) {
+            rawApps.value = repo.loadApps()
+        }
+    }
+
+    fun launch(node: AppNode) {
+        val intent = Intent(Intent.ACTION_MAIN)
+            .addCategory(Intent.CATEGORY_LAUNCHER)
+            .setComponent(ComponentName(node.packageName, node.activityName))
+            .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+        runCatching { getApplication<Application>().startActivity(intent) }
+        viewModelScope.launch { usage.record(node.key) }
+    }
+
+    fun togglePin(node: AppNode) {
+        viewModelScope.launch { usage.setPinned(node.key, !node.pinned) }
+    }
+
+    fun openInfo(node: AppNode) {
+        val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
+            .setData(Uri.fromParts("package", node.packageName, null))
+            .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+        runCatching { getApplication<Application>().startActivity(intent) }
+    }
+
+    fun uninstall(node: AppNode) {
+        @Suppress("DEPRECATION")
+        val intent = Intent(Intent.ACTION_DELETE)
+            .setData(Uri.fromParts("package", node.packageName, null))
+            .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+        runCatching { getApplication<Application>().startActivity(intent) }
+    }
+
+    override fun onCleared() {
+        runCatching { getApplication<Application>().unregisterReceiver(pkgReceiver) }
+    }
+}
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
new file mode 100644
index 0000000..9dbe742
--- /dev/null
+++ b/launcher/src/main/java/com/rizoma/orbita/ui/theme/Theme.kt
@@ -0,0 +1,32 @@
+package com.rizoma.orbita.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.
+val Vacio = Color(0xFF050507)
+val Superficie = Color(0xFF0C0C12)
+val NeonVerde = Color(0xFF7CFF4F)
+val NeonRosa = Color(0xFFFF2E88)
+val NeonCian = Color(0xFF00E5FF)
+val Tenue = Color(0xB3FFFFFF)
+
+private val EsquemaOrbita = darkColorScheme(
+    primary = NeonVerde,
+    onPrimary = Vacio,
+    secondary = NeonRosa,
+    onSecondary = Vacio,
+    tertiary = NeonCian,
+    onTertiary = Vacio,
+    background = Vacio,
+    onBackground = Color.White,
+    surface = Superficie,
+    onSurface = Color.White,
+)
+
+@Composable
+fun OrbitaTheme(content: @Composable () -> Unit) {
+    MaterialTheme(colorScheme = EsquemaOrbita, content = content)
+}
diff --git a/launcher/src/main/res/drawable/ic_launcher_fg.xml b/launcher/src/main/res/drawable/ic_launcher_fg.xml
new file mode 100644
index 0000000..e76686b
--- /dev/null
+++ b/launcher/src/main/res/drawable/ic_launcher_fg.xml
@@ -0,0 +1,27 @@
+<?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">
+    <!-- arcos orbitales excéntricos -->
+    <path
+        android:pathData="M 30 78 A 26 26 0 1 1 78 66"
+        android:strokeColor="#7CFF4F"
+        android:strokeWidth="3.5"
+        android:strokeLineCap="round" />
+    <path
+        android:pathData="M 24 60 A 36 36 0 0 1 84 42"
+        android:strokeColor="#FF2E88"
+        android:strokeWidth="2.5"
+        android:strokeLineCap="round" />
+    <path
+        android:fillColor="#7CFF4F"
+        android:pathData="M 54 78 m -7 0 a 7 7 0 1 1 14 0 a 7 7 0 1 1 -14 0" />
+    <path
+        android:fillColor="#FF2E88"
+        android:pathData="M 78 42 m -4.5 0 a 4.5 4.5 0 1 1 9 0 a 4.5 4.5 0 1 1 -9 0" />
+    <path
+        android:fillColor="#00E5FF"
+        android:pathData="M 30 50 m -3.5 0 a 3.5 3.5 0 1 1 7 0 a 3.5 3.5 0 1 1 -7 0" />
+</vector>
diff --git a/launcher/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/launcher/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..088fcce
--- /dev/null
+++ b/launcher/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/launcher/src/main/res/values/colors.xml b/launcher/src/main/res/values/colors.xml
new file mode 100644
index 0000000..917256d
--- /dev/null
+++ b/launcher/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/launcher/src/main/res/values/strings.xml b/launcher/src/main/res/values/strings.xml
new file mode 100644
index 0000000..4874927
--- /dev/null
+++ b/launcher/src/main/res/values/strings.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <string name="app_name">Órbita</string>
+    <string name="search_hint">buscar en el rizoma…</string>
+    <string name="menu_pin">fijar</string>
+    <string name="menu_unpin">soltar</string>
+    <string name="menu_info">info</string>
+    <string name="menu_uninstall">quitar</string>
+</resources>
diff --git a/launcher/src/main/res/values/themes.xml b/launcher/src/main/res/values/themes.xml
new file mode 100644
index 0000000..7e435ff
--- /dev/null
+++ b/launcher/src/main/res/values/themes.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <style name="Theme.Orbita" 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
new file mode 100644
index 0000000..57e83ba
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,25 @@
+pluginManagement {
+    repositories {
+        google {
+            content {
+                includeGroupByRegex("com\\.android.*")
+                includeGroupByRegex("com\\.google.*")
+                includeGroupByRegex("androidx.*")
+            }
+        }
+        mavenCentral()
+        gradlePluginPortal()
+    }
+}
+dependencyResolutionManagement {
+    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+    repositories {
+        google()
+        mavenCentral()
+    }
+}
+
+rootProject.name = "rizoma"
+include(":launcher")
+// :keyboard se habilitará cuando exista el módulo (siguiente fase)
+// include(":keyboard")