commit 3feee86e320e76ed9ddd32f09ed18d20260c4e2a Author: sergio Date: Fri Jun 12 14:46:00 2026 +0000 Ó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) 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + 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 { + 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 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> = 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> = 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(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, + 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, + 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, + firstR: Float, + ringGap: Float, + spacing: Float, +): List { + if (nodes.isEmpty()) return emptyList() + val result = ArrayList(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>(emptyList()) + + /** Nodos ya ponderados y ordenados: fijados primero, luego por calor, luego alfabético. */ + val nodes: StateFlow> = 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 { 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().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().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().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().startActivity(intent) } + } + + override fun onCleared() { + runCatching { getApplication().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 @@ + + + + + + + + + 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 @@ + + + + + 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 @@ + + + #050507 + 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 @@ + + + Órbita + buscar en el rizoma… + fijar + soltar + info + quitar + 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 @@ + + + + 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")