Ó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>
Delta semántico
Ninguna definición cambió: el cambio es de texto, documentación o datos.
21 archivos · +964 −0
- .gitignore +13 −0
- build.gradle.kts +5 −0
- gradle.properties +7 −0
- gradle/libs.versions.toml +29 −0
- launcher/build.gradle.kts +51 −0
- launcher/proguard-rules.pro +1 −0
- launcher/src/main/AndroidManifest.xml +36 −0
- launcher/src/main/java/com/rizoma/orbita/MainActivity.kt +20 −0
- launcher/src/main/java/com/rizoma/orbita/data/AppRepository.kt +39 −0
- launcher/src/main/java/com/rizoma/orbita/data/UsageStore.kt +54 −0
- launcher/src/main/java/com/rizoma/orbita/model/AppNode.kt +18 −0
- launcher/src/main/java/com/rizoma/orbita/ui/OrbitApp.kt +230 −0
- launcher/src/main/java/com/rizoma/orbita/ui/OrbitLayout.kt +249 −0
- launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt +101 −0
- launcher/src/main/java/com/rizoma/orbita/ui/theme/Theme.kt +32 −0
- launcher/src/main/res/drawable/ic_launcher_fg.xml +27 −0
- launcher/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +5 −0
- launcher/src/main/res/values/colors.xml +4 −0
- launcher/src/main/res/values/strings.xml +9 −0
- launcher/src/main/res/values/themes.xml +9 −0
- settings.gradle.kts +25 −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")