Ó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:
+13
@@ -0,0 +1,13 @@
|
||||
*.iml
|
||||
.gradle/
|
||||
/local.properties
|
||||
.idea/
|
||||
.DS_Store
|
||||
build/
|
||||
captures/
|
||||
.externalNativeBuild/
|
||||
.cxx/
|
||||
*.apk
|
||||
*.aab
|
||||
*.keystore
|
||||
!debug.keystore
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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" }
|
||||
@@ -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)
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
# Reglas específicas del launcher (vacío por ahora)
|
||||
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_bg">#050507</color>
|
||||
</resources>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user