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(":matiz")
|
||||
include(":nimbo")
|
||||
include(":notas")
|
||||
|
||||
Reference in New Issue
Block a user