Ó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>
This commit is contained in:
sergio
2026-06-12 14:46:00 +00:00
co-authored by Claude Opus 4.8
commit 3feee86e32
21 changed files with 964 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
*.iml
.gradle/
/local.properties
.idea/
.DS_Store
build/
captures/
.externalNativeBuild/
.cxx/
*.apk
*.aab
*.keystore
!debug.keystore
+5
View File
@@ -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
}
+7
View File
@@ -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
+29
View File
@@ -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" }
+51
View File
@@ -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)
}
+1
View File
@@ -0,0 +1 @@
# Reglas específicas del launcher (vacío por ahora)
+36
View File
@@ -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>
@@ -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()
}
}
}
}
@@ -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
}
}
@@ -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")
}
}
@@ -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"
}
@@ -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)
}
}
@@ -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
}
@@ -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) }
}
}
@@ -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)
}
@@ -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>
@@ -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>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_bg">#050507</color>
</resources>
+9
View File
@@ -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>
+9
View File
@@ -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>
+25
View File
@@ -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")