Rizoma: nuevo módulo :notas — notas en grafo (zettelkasten rizomático)

App insignia del set: las notas son nodos conectados por enlaces [[wikilink]],
navegables como grafo en vez de lista jerárquica.

- Persistencia sin deps: un notas.json en filesDir (org.json), escritura atómica
  (.tmp + rename) con debounce 350ms; siembra de bienvenida al primer arranque.
- Enlaces [[titulo]] resueltos por título normalizado; aristas dirigidas,
  backlinks (retrocesos) y enlaces "fantasma" que crean la nota al tocarlos.
- Vista grafo: lienzo paneable/zoomable (detectTransformGestures, escala 0.4–3),
  nodos arrastrables con burbuja+halo neón, tamaño por grado; FAB para crear.
- Editor: título+cuerpo (BasicTextField), chips de enlaces en vivo y lista de
  retrocesos; autosalvado.
- Navegación por estado (Grafo|Editor), sin librería. Estética rizoma, offline.

Generado con un subagente bajo spec estricta; escrito NO compilado aquí.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
sergio
2026-06-12 21:00:46 +00:00
co-authored by Claude Opus 4.8
parent cb2d1062c9
commit a116b96ab8
16 changed files with 1126 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "com.rizoma.notas"
compileSdk = 36
defaultConfig {
applicationId = "com.rizoma.notas"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
}
buildTypes {
release {
isMinifyEnabled = false
}
}
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)
}
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- App offline: sin permisos. Las notas viven en el almacenamiento interno. -->
<application
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:theme="@style/Theme.Rizoma"
android:supportsRtl="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:configChanges="orientation|screenSize|keyboard|keyboardHidden|uiMode">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,20 @@
package com.rizoma.notas
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.core.view.WindowCompat
import com.rizoma.notas.ui.RizomaApp
import com.rizoma.notas.ui.theme.RizomaTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
RizomaTheme {
RizomaApp()
}
}
}
}
@@ -0,0 +1,96 @@
package com.rizoma.notas
import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.rizoma.notas.data.NotasRepository
import com.rizoma.notas.model.Grafo
import com.rizoma.notas.model.Nota
import com.rizoma.notas.model.claveTitulo
import kotlinx.coroutines.launch
/** Pantalla actual. Sin librería de navegación: un `when` sobre este estado. */
sealed interface Pantalla {
data object Grafo : Pantalla
data class Editor(val notaId: String) : Pantalla
}
/**
* Estado de la app de notas. Mantiene la lista (recolectada del repositorio) como
* estado Compose y deriva el [Grafo] (aristas/grado/backlinks) bajo demanda.
*/
class NotasViewModel(app: Application) : AndroidViewModel(app) {
private val repo = NotasRepository(app, viewModelScope)
var notas by mutableStateOf<List<Nota>>(emptyList()); private set
var pantalla by mutableStateOf<Pantalla>(Pantalla.Grafo); private set
init {
viewModelScope.launch {
repo.notas.collect { notas = it }
}
}
/** Grafo derivado del estado actual; barato de reconstruir, se recalcula por recomposición. */
fun grafo(): Grafo = Grafo(notas)
fun nota(id: String): Nota? = notas.firstOrNull { it.id == id }
// ---- Navegación ----------------------------------------------------
fun abrir(id: String) { pantalla = Pantalla.Editor(id) }
fun volverAlGrafo() { pantalla = Pantalla.Grafo }
// ---- Mutaciones ----------------------------------------------------
/**
* Crea una nota nueva en (x,y) del mundo y abre su editor. El título es único
* por defecto para no colisionar con resoluciones de enlace.
*/
fun crearEnLienzo(x: Float, y: Float) {
val titulo = tituloLibre("nota")
val id = repo.crear(titulo, "", x, y)
pantalla = Pantalla.Editor(id)
}
fun setTitulo(id: String, titulo: String) = repo.setTitulo(id, titulo)
fun setCuerpo(id: String, cuerpo: String) = repo.setCuerpo(id, cuerpo)
fun mover(id: String, x: Float, y: Float) = repo.mover(id, x, y)
fun eliminar(id: String) {
repo.eliminar(id)
if (pantalla.let { it is Pantalla.Editor && it.notaId == id }) {
pantalla = Pantalla.Grafo
}
}
/**
* Sigue un enlace `[[titulo]]` desde el editor: si la nota destino existe,
* navega a ella; si es fantasma, la crea (cerca del origen) y la abre.
*/
fun seguirEnlace(titulo: String, origenId: String?) {
val destino = notas.firstOrNull { claveTitulo(it.titulo) == claveTitulo(titulo) }
if (destino != null) {
pantalla = Pantalla.Editor(destino.id)
return
}
val origen = origenId?.let { id -> notas.firstOrNull { it.id == id } }
val x = (origen?.x ?: 0f) + 180f
val y = (origen?.y ?: 0f) + 60f
val id = repo.crear(titulo.trim(), "", x, y)
pantalla = Pantalla.Editor(id)
}
/** Genera un título no usado del estilo "base", "base 2", "base 3"... */
private fun tituloLibre(base: String): String {
val usados = notas.mapTo(HashSet()) { claveTitulo(it.titulo) }
if (claveTitulo(base) !in usados) return base
var i = 2
while (claveTitulo("$base $i") in usados) i++
return "$base $i"
}
}
@@ -0,0 +1,173 @@
package com.rizoma.notas.data
import android.content.Context
import com.rizoma.notas.model.Nota
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.util.UUID
/**
* Fuente de verdad de las notas. Carga/guarda una única lista en un fichero JSON
* dentro de `filesDir` (sin Room, sin deps nuevas — usa `org.json`).
*
* El estado vive en [notas] (StateFlow). Cada mutación reemplaza la lista; un
* recolector con `debounce` vuelca a disco en el dispatcher de IO, de modo que
* arrastres y tecleos rápidos no martillean el disco.
*/
@OptIn(FlowPreview::class)
class NotasRepository(context: Context, scope: CoroutineScope) {
private val fichero = File(context.applicationContext.filesDir, FICHERO)
private val _notas = MutableStateFlow(cargar())
val notas: StateFlow<List<Nota>> = _notas.asStateFlow()
init {
// Persistencia reactiva: tras el primer valor (ya en disco), se guarda
// cada cambio con un pequeño retardo para agrupar ráfagas.
scope.launch(Dispatchers.IO) {
_notas
.drop(1) // el estado inicial ya está en disco (o es semilla recién guardada)
.debounce(350)
.collect { guardar(it) }
}
}
// ---- Mutaciones -----------------------------------------------------
/** Crea una nota nueva en (x,y) y devuelve su id. */
fun crear(titulo: String, cuerpo: String, x: Float, y: Float): String {
val ahora = System.currentTimeMillis()
val nota = Nota(
id = UUID.randomUUID().toString(),
titulo = titulo,
cuerpo = cuerpo,
x = x,
y = y,
creada = ahora,
modificada = ahora,
)
_notas.value = _notas.value + nota
return nota.id
}
/** Aplica un cambio a la nota [id]; marca `modificada`. No-op si no existe. */
fun actualizar(id: String, transform: (Nota) -> Nota) {
var cambio = false
val nuevas = _notas.value.map {
if (it.id == id) { cambio = true; transform(it).copy(modificada = System.currentTimeMillis()) }
else it
}
if (cambio) _notas.value = nuevas
}
fun setTitulo(id: String, titulo: String) = actualizar(id) { it.copy(titulo = titulo) }
fun setCuerpo(id: String, cuerpo: String) = actualizar(id) { it.copy(cuerpo = cuerpo) }
/** Reposiciona el nodo en el lienzo (durante/tras un arrastre). */
fun mover(id: String, x: Float, y: Float) = actualizar(id) { it.copy(x = x, y = y) }
fun eliminar(id: String) {
_notas.value = _notas.value.filterNot { it.id == id }
}
fun buscar(id: String?): Nota? = id?.let { i -> _notas.value.firstOrNull { it.id == i } }
// ---- Carga / guardado ----------------------------------------------
private fun cargar(): List<Nota> {
val datos = runCatching {
if (!fichero.exists()) return@runCatching emptyList<Nota>()
val raw = fichero.readText()
if (raw.isBlank()) return@runCatching emptyList<Nota>()
val arr = JSONArray(raw)
(0 until arr.length()).map { i -> notaDeJson(arr.getJSONObject(i)) }
}.getOrElse { emptyList() }
if (datos.isNotEmpty()) return datos
// Primer arranque (o fichero corrupto): notas semilla enlazadas entre sí.
val semilla = semillas()
guardar(semilla) // persiste ya, para que el `drop(1)` no la pise
return semilla
}
private fun guardar(lista: List<Nota>) {
runCatching {
val arr = JSONArray()
for (n in lista) arr.put(notaAJson(n))
// Escritura atómica: a temporal y renombre, para no dejar JSON a medias.
val tmp = File(fichero.parentFile, "$FICHERO.tmp")
tmp.writeText(arr.toString())
if (!tmp.renameTo(fichero)) {
fichero.writeText(arr.toString())
tmp.delete()
}
}
}
private fun notaAJson(n: Nota) = JSONObject().apply {
put("id", n.id)
put("titulo", n.titulo)
put("cuerpo", n.cuerpo)
put("x", n.x.toDouble())
put("y", n.y.toDouble())
put("creada", n.creada)
put("modificada", n.modificada)
}
private fun notaDeJson(o: JSONObject): Nota {
val ahora = System.currentTimeMillis()
return Nota(
id = o.optString("id").ifBlank { UUID.randomUUID().toString() },
titulo = o.optString("titulo"),
cuerpo = o.optString("cuerpo"),
x = o.optDouble("x", 0.0).toFloat(),
y = o.optDouble("y", 0.0).toFloat(),
creada = o.optLong("creada", ahora),
modificada = o.optLong("modificada", ahora),
)
}
private fun semillas(): List<Nota> {
val ahora = System.currentTimeMillis()
fun nota(titulo: String, cuerpo: String, x: Float, y: Float) = Nota(
id = UUID.randomUUID().toString(),
titulo = titulo, cuerpo = cuerpo, x = x, y = y,
creada = ahora, modificada = ahora,
)
return listOf(
nota(
"rizoma",
"esto es un rizoma: no hay carpetas, solo nodos.\n\n" +
"escribe enlaces con dobles corchetes y se vuelven aristas: " +
"[[cómo enlazar]].\n\n" +
"toca un nodo para abrirlo, arrástralo para moverlo.",
-160f, -40f,
),
nota(
"cómo enlazar",
"rodea cualquier título con [[corchetes dobles]] y aparecerá una arista " +
"hacia esa nota.\n\n" +
"si la nota no existe todavía, el enlace sale como [[idea fantasma]]: " +
"tócalo y se crea.\n\n" +
"vuelve al [[rizoma]].",
160f, 60f,
),
)
}
private companion object {
const val FICHERO = "notas.json"
}
}
@@ -0,0 +1,103 @@
package com.rizoma.notas.model
/**
* Un nodo del rizoma. No hay jerarquía: las notas se relacionan por enlaces
* `[[wikilink]]` embebidos en el cuerpo, no por carpetas.
*
* - [id] identidad estable (UUID); sobrevive a renombrados de título.
* - [titulo] cómo la referencian los enlaces (resolución por título, no por id).
* - [cuerpo] texto markdown plano; contiene los `[[...]]`.
* - [x], [y] posición persistida en el lienzo del grafo (coords de "mundo").
* - [creada] / [modificada] epoch millis.
*/
data class Nota(
val id: String,
val titulo: String,
val cuerpo: String,
val x: Float,
val y: Float,
val creada: Long,
val modificada: Long,
) {
/**
* Acento neón estable: derivado del id, así un nodo conserva su color entre
* sesiones aunque cambie su grado o el orden de la lista. 4 colores en la rueda.
*/
val acentoIndice: Int get() = (Math.floorMod(id.hashCode(), 4))
}
/** Normaliza un título para comparar/resolver enlaces (minúsculas + sin espacios extra). */
fun claveTitulo(titulo: String): String = titulo.trim().lowercase()
private val REGEX_ENLACE = Regex("""\[\[([^\]\[]+)\]\]""")
/**
* Extrae los títulos enlazados desde un cuerpo. Devuelve los textos crudos (sin
* normalizar) en orden de aparición y sin duplicados por clave normalizada.
*/
fun enlacesDe(cuerpo: String): List<String> {
val vistos = LinkedHashMap<String, String>()
for (m in REGEX_ENLACE.findAll(cuerpo)) {
val crudo = m.groupValues[1].trim()
if (crudo.isEmpty()) continue
val clave = claveTitulo(crudo)
if (clave !in vistos) vistos[clave] = crudo
}
return vistos.values.toList()
}
/**
* Una arista dirigida del grafo: la nota [desde] enlaza a la nota [hacia] (ambas
* existentes). Se dibuja sin flecha; los backlinks son su inversa.
*/
data class Arista(val desde: String, val hacia: String)
/** Vista derivada del conjunto de notas: índice por título + aristas resueltas. */
class Grafo(val notas: List<Nota>) {
// título normalizado -> nota (la primera gana si hubiera colisión de títulos).
// Primer título gana ante colisiones; LinkedHashMap conserva orden de inserción.
private val porTitulo: Map<String, Nota> = LinkedHashMap<String, Nota>().apply {
for (n in notas) putIfAbsent(claveTitulo(n.titulo), n)
}
/** Resuelve un título (crudo o normalizado) a una nota existente, o null si es fantasma. */
fun resolver(titulo: String): Nota? = porTitulo[claveTitulo(titulo)]
/** Aristas dirigidas entre notas existentes (enlaces fantasma se omiten aquí). */
val aristas: List<Arista> by lazy {
val out = ArrayList<Arista>()
for (n in notas) {
for (t in enlacesDe(n.cuerpo)) {
val destino = porTitulo[claveTitulo(t)] ?: continue
if (destino.id != n.id) out.add(Arista(n.id, destino.id))
}
}
out
}
/** Grado (conexiones únicas, sin dirección) por id de nota; base del tamaño del nodo. */
val grado: Map<String, Int> by lazy {
val pares = HashSet<Long>() // par no dirigido codificado
val cuenta = HashMap<String, Int>()
val indice = notas.withIndex().associate { it.value.id to it.index }
for (a in aristas) {
val i = indice[a.desde] ?: continue
val j = indice[a.hacia] ?: continue
val lo = minOf(i, j).toLong(); val hi = maxOf(i, j).toLong()
if (pares.add(lo * 1_000_003L + hi)) {
cuenta[a.desde] = (cuenta[a.desde] ?: 0) + 1
cuenta[a.hacia] = (cuenta[a.hacia] ?: 0) + 1
}
}
cuenta
}
/** Notas que enlazan a [id] (aristas inversas) = retrocesos / backlinks. */
fun backlinks(id: String): List<Nota> {
val idx = notas.associateBy { it.id }
return aristas.filter { it.hacia == id }
.mapNotNull { idx[it.desde] }
.distinctBy { it.id }
}
}
@@ -0,0 +1,238 @@
package com.rizoma.notas.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
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.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rizoma.notas.NotasViewModel
import com.rizoma.notas.model.Nota
import com.rizoma.notas.model.claveTitulo
import com.rizoma.notas.model.enlacesDe
import com.rizoma.notas.ui.theme.MuyTenue
import com.rizoma.notas.ui.theme.NeonCian
import com.rizoma.notas.ui.theme.NeonRosa
import com.rizoma.notas.ui.theme.NeonVerde
import com.rizoma.notas.ui.theme.SuperficieAlta
import com.rizoma.notas.ui.theme.Tenue
import com.rizoma.notas.ui.theme.Vacio
import androidx.compose.ui.graphics.Color
/**
* Editor de una nota. Título y cuerpo se editan con [BasicTextField]; cada cambio
* se empuja al ViewModel y el repositorio lo persiste con debounce (autosalvado).
*
* Bajo el cuerpo se muestran, en vivo:
* - los `[[enlaces]]` detectados como chips (existentes navegan; fantasmas crean),
* - los "retrocesos" (backlinks): notas que enlazan a esta.
*/
@Composable
fun EditorView(vm: NotasViewModel, notaId: String) {
val nota: Nota? = vm.nota(notaId)
if (nota == null) {
// La nota fue eliminada bajo nuestros pies: vuelve al grafo.
Box(Modifier.fillMaxSize().background(Vacio))
return
}
// Estado de edición local, sembrado una vez por nota. Las pulsaciones rápidas
// actualizan aquí y se propagan al VM; no re-sembramos en cada recomposición.
var titulo by remember(notaId) { mutableStateOf(nota.titulo) }
var cuerpo by remember(notaId) { mutableStateOf(nota.cuerpo) }
val enlaces = remember(cuerpo) { enlacesDe(cuerpo) }
val existentes = remember(vm.notas) { vm.notas.mapTo(HashSet()) { claveTitulo(it.titulo) } }
val backlinks = vm.grafo().backlinks(notaId)
Column(
Modifier
.fillMaxSize()
.background(Vacio)
.windowInsetsPadding(WindowInsets.systemBars)
.padding(20.dp),
) {
// Barra superior: volver + eliminar.
Row(
Modifier.fillMaxWidth().padding(bottom = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
" grafo",
color = Tenue,
fontSize = 15.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier
.clip(RoundedCornerShape(50))
.clickable { vm.volverAlGrafo() }
.padding(horizontal = 12.dp, vertical = 8.dp),
)
Text(
"eliminar",
color = NeonRosa,
fontSize = 13.sp,
modifier = Modifier
.clip(RoundedCornerShape(50))
.clickable { vm.eliminar(notaId) }
.padding(horizontal = 12.dp, vertical = 8.dp),
)
}
// Título.
BasicTextField(
value = titulo,
onValueChange = { titulo = it; vm.setTitulo(notaId, it) },
textStyle = TextStyle(color = Color.White, fontSize = 26.sp, fontWeight = FontWeight.Bold),
cursorBrush = SolidColor(NeonVerde),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
decorationBox = { inner ->
if (titulo.isEmpty()) {
Text("título", color = MuyTenue, fontSize = 26.sp, fontWeight = FontWeight.Bold)
}
inner()
},
)
Spacer(Modifier.padding(top = 6.dp))
// Cuerpo (markdown plano; admite [[enlaces]]). Ocupa el espacio central.
BasicTextField(
value = cuerpo,
onValueChange = { cuerpo = it; vm.setCuerpo(notaId, it) },
textStyle = TextStyle(color = Tenue, fontSize = 16.sp, lineHeight = 24.sp),
cursorBrush = SolidColor(NeonVerde),
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.verticalScroll(rememberScrollState()),
decorationBox = { inner ->
if (cuerpo.isEmpty()) {
Text(
"escribe… enlaza con [[otra nota]]",
color = MuyTenue,
fontSize = 16.sp,
)
}
inner()
},
)
// Chips de enlaces detectados.
if (enlaces.isNotEmpty()) {
Etiqueta("enlaces")
Row(
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(top = 6.dp, bottom = 4.dp),
) {
for (e in enlaces) {
val existe = claveTitulo(e) in existentes
Chip(
texto = if (existe) e else "$e +",
acento = if (existe) NeonCian else MuyTenue,
fantasma = !existe,
onClick = { vm.seguirEnlace(e, notaId) },
)
Spacer(Modifier.width(8.dp))
}
}
}
// Retrocesos (backlinks): quién enlaza aquí.
if (backlinks.isNotEmpty()) {
Etiqueta("retrocesos")
Column(
Modifier
.fillMaxWidth()
.heightIn(max = 140.dp)
.verticalScroll(rememberScrollState())
.padding(top = 6.dp),
) {
for (b in backlinks) {
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.clickable { vm.abrir(b.id) }
.padding(vertical = 8.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("", color = NeonRosa, fontSize = 14.sp, modifier = Modifier.width(22.dp))
Text(
b.titulo.ifBlank { "sin título" },
color = Tenue,
fontSize = 15.sp,
maxLines = 1,
)
}
}
}
}
}
}
@Composable
private fun Etiqueta(texto: String) {
Text(
texto.uppercase(),
color = MuyTenue,
fontSize = 10.sp,
letterSpacing = 3.sp,
modifier = Modifier.padding(top = 10.dp),
)
}
@Composable
private fun Chip(texto: String, acento: Color, fantasma: Boolean, onClick: () -> Unit) {
Box(
Modifier
.clip(RoundedCornerShape(50))
.background(SuperficieAlta)
.border(
width = 1.dp,
color = if (fantasma) acento.copy(alpha = 0.5f) else acento,
shape = RoundedCornerShape(50),
)
.clickable { onClick() }
.padding(horizontal = 14.dp, vertical = 8.dp),
) {
Text(
texto,
color = if (fantasma) MuyTenue else Color.White,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
)
}
}
@@ -0,0 +1,314 @@
package com.rizoma.notas.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rizoma.notas.NotasViewModel
import com.rizoma.notas.model.Nota
import com.rizoma.notas.ui.theme.Acentos
import com.rizoma.notas.ui.theme.MuyTenue
import com.rizoma.notas.ui.theme.SuperficieAlta
import com.rizoma.notas.ui.theme.Tenue
import com.rizoma.notas.ui.theme.Vacio
import kotlin.math.roundToInt
private const val ESCALA_MIN = 0.4f
private const val ESCALA_MAX = 3f
/**
* Vista grafo: el lienzo rizomático. Se panea/zooma con [detectTransformGestures]
* sobre el contenedor y el "mundo" se transforma con [graphicsLayer]
* (translation + scale, origen arriba-izquierda).
*
* Detalle clave de coordenadas: las notas y nodos se posicionan en coords de
* "mundo"; como los nodos viven *dentro* del graphicsLayer, los desplazamientos
* de arrastre que reciben ya vienen en unidades de mundo (Compose invierte la
* transformación del layer al repartir los punteros), así que se suman directos.
*/
@Composable
fun GraphView(vm: NotasViewModel) {
val grafo = vm.grafo()
val grados = grafo.grado
// Transformación del viewport.
var escala by remember { mutableStateOf(1f) }
var desplazamiento by remember { mutableStateOf(Offset.Zero) }
var viewport by remember { mutableStateOf(IntSize.Zero) }
var centrado by remember { mutableStateOf(false) }
// Nodo seleccionado por pulsación larga (muestra barra de acciones).
var seleccionado by remember { mutableStateOf<String?>(null) }
Box(
Modifier
.fillMaxSize()
.background(Vacio)
.clipToBounds()
.onSizeChanged {
viewport = it
if (!centrado && it.width > 0 && it.height > 0) {
// Origen de mundo (0,0) al centro del viewport en el primer layout.
desplazamiento = Offset(it.width / 2f, it.height / 2f)
centrado = true
}
}
.pointerInput(Unit) {
detectTransformGestures { centroide, pan, zoom, _ ->
val nueva = (escala * zoom).coerceIn(ESCALA_MIN, ESCALA_MAX)
// Zoom anclado al centroide: el punto de mundo bajo los dedos no se mueve.
desplazamiento = centroide - (centroide - desplazamiento) * (nueva / escala) + pan
escala = nueva
seleccionado = null
}
},
) {
// El "mundo": todo lo que se panea/zooma cuelga de aquí.
Box(
Modifier
.fillMaxSize()
.graphicsLayer {
translationX = desplazamiento.x
translationY = desplazamiento.y
scaleX = escala
scaleY = escala
transformOrigin = TransformOrigin(0f, 0f)
},
) {
// Aristas detrás (líneas tenues entre nodos enlazados existentes).
val porId = remember(vm.notas) { vm.notas.associateBy { it.id } }
Canvas(Modifier.fillMaxSize()) {
for (a in grafo.aristas) {
val d = porId[a.desde] ?: continue
val h = porId[a.hacia] ?: continue
drawLine(
color = Color.White.copy(alpha = 0.10f),
start = Offset(d.x, d.y),
end = Offset(h.x, h.y),
strokeWidth = 1.5.dp.toPx(),
)
}
}
// Nodos encima.
for (nota in vm.notas) {
NodoGrafo(
nota = nota,
grado = grados[nota.id] ?: 0,
seleccionado = seleccionado == nota.id,
onTap = { vm.abrir(nota.id) },
onLongPress = { seleccionado = nota.id },
// Lee la posición actual desde el VM (el closure del gesto no se
// recompone): así el arrastre acumula sobre la última posición.
onDrag = { dx, dy ->
val cur = vm.nota(nota.id)
if (cur != null) vm.mover(nota.id, cur.x + dx, cur.y + dy)
},
)
}
}
// FAB "+": crea una nota en el centro del viewport actual (coords de mundo).
Surface(
color = SuperficieAlta,
shape = RoundedCornerShape(50),
modifier = Modifier
.align(Alignment.BottomEnd)
.windowInsetsPadding(WindowInsets.systemBars)
.padding(24.dp)
.size(60.dp)
.clip(RoundedCornerShape(50))
.clickable {
val s = escala.coerceAtLeast(0.0001f)
val cx = (viewport.width / 2f - desplazamiento.x) / s
val cy = (viewport.height / 2f - desplazamiento.y) / s
vm.crearEnLienzo(cx, cy)
},
) {
Box(contentAlignment = Alignment.Center) {
Text("+", color = Tenue, fontSize = 30.sp, fontWeight = FontWeight.Light)
}
}
// Pista cuando el lienzo está vacío.
if (vm.notas.isEmpty()) {
Text(
"toca + para sembrar la primera nota",
color = MuyTenue,
fontSize = 14.sp,
modifier = Modifier.align(Alignment.Center),
)
}
// Barra de acciones por pulsación larga.
val sel = seleccionado
if (sel != null) {
BarraNodo(
titulo = vm.nota(sel)?.titulo.orEmpty(),
onEliminar = { vm.eliminar(sel); seleccionado = null },
onCancelar = { seleccionado = null },
modifier = Modifier
.align(Alignment.BottomCenter)
.windowInsetsPadding(WindowInsets.systemBars)
.padding(20.dp),
)
}
}
}
/**
* Un nodo: burbuja con halo neón cuyo tamaño crece con el grado (nº de conexiones).
* Tap abre el editor; pulsación larga lo selecciona; arrastre lo reposiciona.
*/
@Composable
private fun NodoGrafo(
nota: Nota,
grado: Int,
seleccionado: Boolean,
onTap: () -> Unit,
onLongPress: () -> Unit,
onDrag: (Float, Float) -> Unit,
) {
val acento = Acentos[nota.acentoIndice % Acentos.size]
// Diámetro: base + aporte por grado, acotado. Sin tamaños negativos.
val diametroDp = (54 + grado.coerceIn(0, 8) * 9).dp
// La caja mide el doble para dejar sitio al halo; su centro cae sobre (x,y).
val cajaDp = diametroDp * 2
Box(
Modifier
// Centra la caja sobre (x,y): el offset coloca la esquina sup-izq,
// así que restamos medio lado (= diametroDp en px) a cada coord.
.offset {
val medio = diametroDp.toPx()
IntOffset((nota.x - medio).roundToInt(), (nota.y - medio).roundToInt())
}
.size(cajaDp)
// Gestos: arrastre (reposiciona) y tap/pulsación-larga.
.pointerInput(nota.id) {
detectDragGestures { change, drag ->
change.consume()
onDrag(drag.x, drag.y)
}
}
.pointerInput(nota.id) {
detectTapGestures(
onTap = { onTap() },
onLongPress = { onLongPress() },
)
},
contentAlignment = Alignment.Center,
) {
Canvas(Modifier.size(diametroDp)) {
val r = size.minDimension / 2f
val centro = Offset(size.width / 2f, size.height / 2f)
// Halo neón (anillos concéntricos translúcidos).
drawCircle(color = acento.copy(alpha = 0.16f), radius = r, center = centro)
drawCircle(color = acento.copy(alpha = 0.10f), radius = r * 1.25f, center = centro)
// Cuerpo de la burbuja.
drawCircle(color = SuperficieAlta, radius = r * 0.82f, center = centro)
// Borde neón (más intenso si está seleccionado).
drawCircle(
color = if (seleccionado) acento else acento.copy(alpha = 0.85f),
radius = r * 0.82f,
center = centro,
style = androidx.compose.ui.graphics.drawscope.Stroke(
width = if (seleccionado) 3.5.dp.toPx() else 2.dp.toPx(),
),
)
}
Text(
text = nota.titulo.ifBlank { "·" },
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
maxLines = 2,
textAlign = TextAlign.Center,
modifier = Modifier.width(diametroDp * 0.85f),
)
}
}
@Composable
private fun BarraNodo(
titulo: String,
onEliminar: () -> Unit,
onCancelar: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(color = SuperficieAlta, shape = RoundedCornerShape(16.dp), modifier = modifier.fillMaxWidth()) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
titulo.ifBlank { "sin título" },
color = Tenue,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
modifier = Modifier.padding(end = 12.dp),
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"cancelar",
color = MuyTenue,
fontSize = 14.sp,
modifier = Modifier
.clip(RoundedCornerShape(50))
.clickable { onCancelar() }
.padding(horizontal = 12.dp, vertical = 8.dp),
)
Text(
"eliminar",
color = com.rizoma.notas.ui.theme.NeonRosa,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.clip(RoundedCornerShape(50))
.clickable { onEliminar() }
.padding(horizontal = 12.dp, vertical = 8.dp),
)
}
}
}
}
@@ -0,0 +1,24 @@
package com.rizoma.notas.ui
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.lifecycle.viewmodel.compose.viewModel
import com.rizoma.notas.NotasViewModel
import com.rizoma.notas.Pantalla
/**
* Raíz de navegación. Dos vistas conmutables (grafo / editor) seleccionadas por
* un `when` sobre [Pantalla], al estilo de :consola. El editor captura el botón
* atrás para volver al grafo en vez de cerrar la app.
*/
@Composable
fun RizomaApp(vm: NotasViewModel = viewModel()) {
when (val p = vm.pantalla) {
Pantalla.Grafo -> GraphView(vm)
is Pantalla.Editor -> {
BackHandler { vm.volverAlGrafo() }
EditorView(vm, notaId = p.notaId)
}
}
}
@@ -0,0 +1,40 @@
package com.rizoma.notas.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 (compartida con Órbita/Trazo/Consola/Matiz).
val Vacio = Color(0xFF050507)
val Superficie = Color(0xFF0C0C12)
val SuperficieAlta = Color(0xFF14141C)
val NeonVerde = Color(0xFF7CFF4F)
val NeonRosa = Color(0xFFFF2E88)
val NeonCian = Color(0xFF00E5FF)
val NeonViola = Color(0xFFB45CFF)
val Tenue = Color(0xB3FFFFFF)
val MuyTenue = Color(0x66FFFFFF)
// Rueda de acentos neón; se reparten por nodo de forma estable (ver Nota.acento).
val Acentos = listOf(NeonVerde, NeonRosa, NeonCian, NeonViola)
private val EsquemaNotas = darkColorScheme(
primary = NeonVerde,
onPrimary = Vacio,
secondary = NeonRosa,
onSecondary = Vacio,
tertiary = NeonCian,
onTertiary = Vacio,
background = Vacio,
onBackground = Color.White,
surface = Superficie,
onSurface = Color.White,
surfaceVariant = SuperficieAlta,
onSurfaceVariant = Tenue,
)
@Composable
fun RizomaTheme(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = EsquemaNotas, content = content)
}
@@ -0,0 +1,22 @@
<?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">
<!-- un grafo en miniatura: cuatro nodos neón unidos por aristas tenues -->
<!-- aristas primero, para que los nodos queden encima -->
<path android:strokeColor="#33FFFFFF" android:strokeWidth="2"
android:pathData="M38 40 L70 34" />
<path android:strokeColor="#33FFFFFF" android:strokeWidth="2"
android:pathData="M38 40 L46 70" />
<path android:strokeColor="#33FFFFFF" android:strokeWidth="2"
android:pathData="M70 34 L46 70" />
<path android:strokeColor="#33FFFFFF" android:strokeWidth="2"
android:pathData="M46 70 L74 72" />
<!-- nodos -->
<path android:fillColor="#7CFF4F" android:pathData="M38 40 m-9 0 a9 9 0 1 0 18 0 a9 9 0 1 0 -18 0 z" />
<path android:fillColor="#00E5FF" android:pathData="M70 34 m-7 0 a7 7 0 1 0 14 0 a7 7 0 1 0 -14 0 z" />
<path android:fillColor="#FF2E88" android:pathData="M46 70 m-10 0 a10 10 0 1 0 20 0 a10 10 0 1 0 -20 0 z" />
<path android:fillColor="#B45CFF" android:pathData="M74 72 m-6 0 a6 6 0 1 0 12 0 a6 6 0 1 0 -12 0 z" />
</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>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">rizoma</string>
</resources>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Rizoma" 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>
+1
View File
@@ -25,3 +25,4 @@ include(":keyboard")
include(":consola") include(":consola")
include(":matiz") include(":matiz")
include(":nimbo") include(":nimbo")
include(":notas")