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>
Semantic delta
No definition changed: this change is text, documentation or data.
16 files · +1126 −0
- notas/build.gradle.kts +50 −0
- notas/src/main/AndroidManifest.xml +23 −0
- notas/src/main/java/com/rizoma/notas/MainActivity.kt +20 −0
- notas/src/main/java/com/rizoma/notas/NotasViewModel.kt +96 −0
- notas/src/main/java/com/rizoma/notas/data/NotasRepository.kt +173 −0
- notas/src/main/java/com/rizoma/notas/model/Nota.kt +103 −0
- notas/src/main/java/com/rizoma/notas/ui/EditorView.kt +238 −0
- notas/src/main/java/com/rizoma/notas/ui/GraphView.kt +314 −0
- notas/src/main/java/com/rizoma/notas/ui/RizomaApp.kt +24 −0
- notas/src/main/java/com/rizoma/notas/ui/theme/Theme.kt +40 −0
- notas/src/main/res/drawable/ic_launcher_fg.xml +22 −0
- notas/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +5 −0
- notas/src/main/res/values/colors.xml +4 −0
- notas/src/main/res/values/strings.xml +4 −0
- notas/src/main/res/values/themes.xml +9 −0
- settings.gradle.kts +1 −0
diff --git a/notas/build.gradle.kts b/notas/build.gradle.kts
new file mode 100644
index 0000000..a82a024
--- /dev/null
+++ b/notas/build.gradle.kts
@@ -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)
+}
diff --git a/notas/src/main/AndroidManifest.xml b/notas/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..76b809a
--- /dev/null
+++ b/notas/src/main/AndroidManifest.xml
@@ -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>
diff --git a/notas/src/main/java/com/rizoma/notas/MainActivity.kt b/notas/src/main/java/com/rizoma/notas/MainActivity.kt
new file mode 100644
index 0000000..3c96986
--- /dev/null
+++ b/notas/src/main/java/com/rizoma/notas/MainActivity.kt
@@ -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()
+ }
+ }
+ }
+}
diff --git a/notas/src/main/java/com/rizoma/notas/NotasViewModel.kt b/notas/src/main/java/com/rizoma/notas/NotasViewModel.kt
new file mode 100644
index 0000000..a98f155
--- /dev/null
+++ b/notas/src/main/java/com/rizoma/notas/NotasViewModel.kt
@@ -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"
+ }
+}
diff --git a/notas/src/main/java/com/rizoma/notas/data/NotasRepository.kt b/notas/src/main/java/com/rizoma/notas/data/NotasRepository.kt
new file mode 100644
index 0000000..a809af9
--- /dev/null
+++ b/notas/src/main/java/com/rizoma/notas/data/NotasRepository.kt
@@ -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"
+ }
+}
diff --git a/notas/src/main/java/com/rizoma/notas/model/Nota.kt b/notas/src/main/java/com/rizoma/notas/model/Nota.kt
new file mode 100644
index 0000000..a01b0a8
--- /dev/null
+++ b/notas/src/main/java/com/rizoma/notas/model/Nota.kt
@@ -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 }
+ }
+}
diff --git a/notas/src/main/java/com/rizoma/notas/ui/EditorView.kt b/notas/src/main/java/com/rizoma/notas/ui/EditorView.kt
new file mode 100644
index 0000000..4994a80
--- /dev/null
+++ b/notas/src/main/java/com/rizoma/notas/ui/EditorView.kt
@@ -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,
+ )
+ }
+}
diff --git a/notas/src/main/java/com/rizoma/notas/ui/GraphView.kt b/notas/src/main/java/com/rizoma/notas/ui/GraphView.kt
new file mode 100644
index 0000000..514678a
--- /dev/null
+++ b/notas/src/main/java/com/rizoma/notas/ui/GraphView.kt
@@ -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),
+ )
+ }
+ }
+ }
+}
diff --git a/notas/src/main/java/com/rizoma/notas/ui/RizomaApp.kt b/notas/src/main/java/com/rizoma/notas/ui/RizomaApp.kt
new file mode 100644
index 0000000..0d8cecd
--- /dev/null
+++ b/notas/src/main/java/com/rizoma/notas/ui/RizomaApp.kt
@@ -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)
+ }
+ }
+}
diff --git a/notas/src/main/java/com/rizoma/notas/ui/theme/Theme.kt b/notas/src/main/java/com/rizoma/notas/ui/theme/Theme.kt
new file mode 100644
index 0000000..ae60e62
--- /dev/null
+++ b/notas/src/main/java/com/rizoma/notas/ui/theme/Theme.kt
@@ -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)
+}
diff --git a/notas/src/main/res/drawable/ic_launcher_fg.xml b/notas/src/main/res/drawable/ic_launcher_fg.xml
new file mode 100644
index 0000000..7aaa343
--- /dev/null
+++ b/notas/src/main/res/drawable/ic_launcher_fg.xml
@@ -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>
diff --git a/notas/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/notas/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..088fcce
--- /dev/null
+++ b/notas/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+ <background android:drawable="@color/ic_bg" />
+ <foreground android:drawable="@drawable/ic_launcher_fg" />
+</adaptive-icon>
diff --git a/notas/src/main/res/values/colors.xml b/notas/src/main/res/values/colors.xml
new file mode 100644
index 0000000..917256d
--- /dev/null
+++ b/notas/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <color name="ic_bg">#050507</color>
+</resources>
diff --git a/notas/src/main/res/values/strings.xml b/notas/src/main/res/values/strings.xml
new file mode 100644
index 0000000..dcfeaa2
--- /dev/null
+++ b/notas/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="app_name">rizoma</string>
+</resources>
diff --git a/notas/src/main/res/values/themes.xml b/notas/src/main/res/values/themes.xml
new file mode 100644
index 0000000..43af6c4
--- /dev/null
+++ b/notas/src/main/res/values/themes.xml
@@ -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>
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 17ccf4b..190a27e 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -25,3 +25,4 @@ include(":keyboard")
include(":consola")
include(":matiz")
include(":nimbo")
+include(":notas")