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:
co-authored by
Claude Opus 4.8
parent
cb2d1062c9
commit
a116b96ab8
@@ -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)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_bg">#050507</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">rizoma</string>
|
||||||
|
</resources>
|
||||||
@@ -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>
|
||||||
@@ -25,3 +25,4 @@ include(":keyboard")
|
|||||||
include(":consola")
|
include(":consola")
|
||||||
include(":matiz")
|
include(":matiz")
|
||||||
include(":nimbo")
|
include(":nimbo")
|
||||||
|
include(":notas")
|
||||||
|
|||||||
Reference in New Issue
Block a user