feat: v0.2.0 — sincronização do botão garantida, SSE em tempo real, WYSIWYG/Preview e lista rica

Sync (bug do celular real):
- mudanças só de metadados (título/formato/categorias) entram na fila
- auto-reconciliação: toda rodada enfileira notas sujas sem entrada na fila
- re-enfileira update se o usuário digitou durante o push
- sync no ON_RESUME; botão Sincronizar com spinner no lugar do ícone

Tempo real (SSE):
- SseClient (OkHttp, Dispatchers.IO, backoff) consome /api/events com Bearer
- saved: nota limpa recarrega sozinha (editor aberto incluído, com rekey por meta),
  nota suja mostra banner Recarregar/Manter; snackbar na lista
- presence: 👥N na lista/editor quando outra sessão (web ou app) abre a mesma nota
- SessionId único por instalação, enviado em presence e ecoado nos saves

Editor moderno (WebView nativo, sem deps novas):
- modos Editar / WYSIWYG / Preview; WYSIWYG = contenteditable com serializador
  html→md em JS na página-asset + ponte @JavascriptInterface (tudo na main thread)
- toolbar Markdown no modo texto (wrap/prefix seleção) e execCommand no WYSIWYG
- Preview renderiza md via /api/render; links não navegam fora do app
- lista: MediumTopAppBar, chips de filtro (categoria com caminho da árvore e tags),
  cards com status ☁/●/🗑/ e 👥, FAB estendido; telas em Material 3 dynamic color

Testado no emulador Android 15: título-only sync ✅, SSE ao vivo ✅, WYSIWYG→servidor ✅,
👥 presença ✅, conflito 409 com cópia única ✅
This commit is contained in:
lgcosta committed 2026-11-04 20:52:59 -03:00
1 parent 9e0bcc3e9b
commit 61d4abebbd
12 files changed
+1096 -154

No files matched your search

@@ -2,27 +2,60 @@ package com.labexposed.notas.ui
import com.labexposed.notas.SyncBanner
import com.labexposed.notas.domain.Settings
import androidx.compose.animation.animateColorAsState
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.Button
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CloudDone
import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material.icons.filled.CloudSync
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedAssistChip
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -31,83 +64,123 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.filled.Add
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.isSystemInDarkTheme
import com.labexposed.notas.data.local.NoteEntity
import com.labexposed.notas.domain.SyncPhase
import com.labexposed.notas.domain.SyncUiState
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
private val AUTOSAVE_IDLE_MS = 10_000L
/** Tela de configuração: URL do servidor + token + autor, com teste de conexão. */
private fun ago(ms: Long): String {
val d = (System.currentTimeMillis() - ms) / 1000
return when {
d < 45 -> "agora"
d < 3600 -> "há ${d / 60} min"
d < 86400 -> "há ${d / 3600} h"
else -> java.text.DateFormat.getDateInstance(java.text.DateFormat.SHORT).format(java.util.Date(ms))
}
}
private fun extOf(format: String) = when (format) { "md" -> ".md"; "html" -> ".html"; else -> ".txt" }
/* ================================================================ Servidor */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(vm: NotasViewModel, onDone: () -> Unit) {
val settings by vm.settings.collectAsState()
var url by remember { mutableStateOf(settings.serverUrl) }
var token by remember { mutableStateOf(settings.token) }
var author by remember { mutableStateOf(settings.author) }
var url by remember { mutableStateOf(TextFieldValue(settings.serverUrl)) }
var token by remember { mutableStateOf(TextFieldValue(settings.token)) }
var author by remember { mutableStateOf(TextFieldValue(settings.author)) }
var feedback by remember { mutableStateOf("") }
val scope = rememberCoroutineScope()
val events by vm.events.collectAsState()
LaunchedEffect(events) {
when (val e = events) {
is ListEvent.ConnectionOk -> feedback = "OK! ${e.notes} nota(s) visíveis no servidor."
is ListEvent.ConnectionFail -> feedback = "Falhou: ${e.message}"
is ListEvent.ConnectionOk -> feedback = "✓ Conectado — ${e.notes} nota(s) no servidor."
is ListEvent.ConnectionFail -> feedback = "✗ ${e.message}"
is ListEvent.RemoteChange -> {}
null -> {}
}
if (events != null) vm.consumeEvent()
}
Scaffold(topBar = { TopAppBar(title = { Text("Servidor") }) }) { pad ->
Scaffold(topBar = { MediumTopAppBar(title = { Text("Servidor") }) }) { pad ->
Column(
modifier = Modifier.padding(pad).padding(16.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(pad).padding(horizontal = 20.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
"Conecte o app à sua instância do notas-online. Tudo funciona offline; " +
"as alterações sincronizam quando houver rede.",
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = url, onValueChange = { url = it },
label = { Text("URL da API (ex.: http://192.168.0.10:8080)") },
singleLine = true, modifier = Modifier.fillMaxWidth(),
label = { Text("URL da API") },
placeholder = { Text("http://192.168.0.10:8080") },
singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(14.dp),
)
OutlinedTextField(
value = token, onValueChange = { token = it },
label = { Text("Token de acesso (NOTES_TOKEN)") },
singleLine = true, modifier = Modifier.fillMaxWidth(),
singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(14.dp),
)
OutlinedTextField(
value = author, onValueChange = { author = it },
label = { Text("Autor das revisões") },
singleLine = true, modifier = Modifier.fillMaxWidth(),
singleLine = true, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(14.dp),
)
if (feedback.isNotEmpty()) {
Card(modifier = Modifier.fillMaxWidth()) {
Text(feedback, modifier = Modifier.padding(12.dp), style = MaterialTheme.typography.bodyMedium)
Surface(
color = if (feedback.startsWith("✓")) MaterialTheme.colorScheme.secondaryContainer
else MaterialTheme.colorScheme.errorContainer,
shape = RoundedCornerShape(12.dp),
) {
Text(feedback, Modifier.padding(12.dp), style = MaterialTheme.typography.bodyMedium)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { scope.launch { vm.testConnection(Settings(url, token, author)) } }) {
Text("Testar conexão")
}
TextButton(
enabled = url.isNotBlank() && token.isNotBlank(),
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
TextButton(onClick = {
scope.launch { vm.testConnection(Settings(url.text, token.text, author.text)) }
}) { Text("Testar conexão") }
Spacer(Modifier.weight(1f))
androidx.compose.material3.Button(
enabled = url.text.isNotBlank() && token.text.isNotBlank() &&
(url.text.startsWith("http://") || url.text.startsWith("https://")),
onClick = {
vm.saveSettings(Settings(url, token, author))
vm.saveSettings(Settings(url.text, token.text, author.text))
onDone()
},
shape = RoundedCornerShape(14.dp),
) { Text("Salvar e continuar") }
}
}
}
}
/** Tela principal: lista local de notas + banner de sincronização. */
@OptIn(ExperimentalMaterial3Api::class)
/* ==================================================================== Lista */
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun NotesListScreen(
vm: NotasViewModel,
@@ -118,163 +191,497 @@ fun NotesListScreen(
val notes by vm.notes.collectAsState()
val sync by vm.syncState.collectAsState()
val cats by vm.categories.collectAsState()
val catNames = remember(cats) { cats.associate { it.id to it.name } }
val presence by vm.presence.collectAsState()
val remoteChanged by vm.remoteChanged.collectAsState()
var filterCat by remember { mutableStateOf<String?>(null) }
var filterTag by remember { mutableStateOf<String?>(null) }
val snackbar = remember { SnackbarHostState() }
val catPaths = remember(cats) {
val byId = cats.associateBy { it.id }
fun path(id: String): String {
val parts = mutableListOf<String>()
var cur = byId[id]
var guard = 0
while (cur != null && guard++ < 10) {
parts.add(0, cur.name)
cur = byId[cur.parent]
}
return parts.joinToString("/")
}
cats.associate { it.id to path(it.id) }
}
val allTags = remember(notes) {
notes.flatMap { it.tags.split(",").filter { t -> t.isNotBlank() } }.distinct().sorted()
}
val shown = remember(notes, filterCat, filterTag) {
notes.filter { n ->
(filterCat == null || n.categories.split(",").contains(filterCat)) &&
(filterTag == null || n.tags.split(",").contains(filterTag))
}
}
val events by vm.events.collectAsState()
LaunchedEffect(events) {
if (events is ListEvent.RemoteChange) {
snackbar.showSnackbar("“${(events as ListEvent.RemoteChange).title}” foi atualizada pelo servidor")
vm.consumeEvent()
}
}
Scaffold(
snackbarHost = { SnackbarHost(snackbar) },
topBar = {
TopAppBar(
MediumTopAppBar(
title = { Text("Notas") },
actions = {
TextButton(onClick = { vm.syncNow() }) { Text("Sincronizar") }
TextButton(onClick = onSettings) { Text("Servidor") }
IconButton(onClick = { vm.syncNow() }) {
if (sync.phase == SyncPhase.Syncing) {
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
} else {
Icon(Icons.Default.CloudSync, contentDescription = "Sincronizar agora")
}
}
IconButton(onClick = onSettings) {
Icon(Icons.Default.Description, contentDescription = "Servidor")
}
},
colors = TopAppBarDefaults.mediumTopAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
),
)
},
floatingActionButton = {
androidx.compose.material3.FloatingActionButton(onClick = onNew) {
Icon(Icons.Default.Add, contentDescription = "Nova nota")
}
ExtendedFloatingActionButton(
onClick = onNew,
icon = { Icon(Icons.Default.Add, null) },
text = { Text("Nova") },
)
},
) { pad ->
Column(modifier = Modifier.padding(pad).fillMaxSize()) {
SyncBanner(sync)
if (notes.isEmpty()) {
Text(
"Nenhuma nota local.\nToque em + para criar (funciona offline).",
modifier = Modifier.padding(24.dp),
style = MaterialTheme.typography.bodyMedium,
)
SyncBanner(sync, Modifier.padding(horizontal = 12.dp))
if (cats.isNotEmpty() || allTags.isNotEmpty()) {
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState())
.padding(horizontal = 12.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
FilterChip(filterCat == null && filterTag == null, { filterCat = null; filterTag = null },
label = { Text("Todas") })
cats.take(12).forEach { c ->
FilterChip(filterCat == c.id, { filterCat = c.id; filterTag = null },
label = { Text("📁 " + (catPaths[c.id] ?: c.name)) })
}
allTags.take(12).forEach { t ->
FilterChip(filterTag == t, { filterTag = t; filterCat = null },
label = { Text("#$t") })
}
}
}
if (shown.isEmpty()) {
Box(Modifier.fillMaxSize().weight(1f), contentAlignment = Alignment.Center) {
Text(
if (notes.isEmpty()) "Nenhuma nota ainda.\nToque em “Nova” — funciona offline."
else "Nada com esse filtro.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(8.dp),
contentPadding = PaddingValues(start = 12.dp, end = 12.dp, bottom = 96.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(notes, key = { it.id }) { note ->
androidx.compose.material3.Card(
items(shown, key = { it.id }) { note ->
NoteCard(
note = note,
catPaths = catPaths,
others = presence[note.id] ?: 0,
remotelyChanged = remoteChanged.contains(note.id),
onClick = { onOpen(note.id) },
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
Row(
modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(Modifier.weight(1f)) {
Text(
(note.title.ifBlank { "Sem título" }) +
when (note.format) { "md" -> ".md"; "html" -> ".html"; else -> ".txt" },
style = MaterialTheme.typography.titleSmall,
)
val bits = mutableListOf(
java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT)
.format(java.util.Date(note.updatedAt)),
)
note.categories.split(",").filter { it.isNotBlank() }
.map { catNames[it] ?: it }.ifNotEmpty()?.let { bits.add(it.joinToString(" · ")) }
if (note.tags.isNotBlank()) bits.add(note.tags.split(",").joinToString(" ") { "#$it" })
Text(bits.joinToString(" · "), style = MaterialTheme.typography.bodySmall)
if (note.conflictNote.isNotBlank()) {
Text("⚠ ${note.conflictNote}", style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error)
}
}
Column(horizontalAlignment = androidx.compose.ui.Alignment.End) {
if (note.dirty) Text("● não enviado", color = MaterialTheme.colorScheme.tertiary,
style = MaterialTheme.typography.labelSmall)
if (note.pendingDelete) Text("🗑 fila", style = MaterialTheme.typography.labelSmall)
}
}
}
)
}
}
}
}
}
private fun <T> List<T>.ifNotEmpty(): List<T>? = if (isEmpty()) null else this
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
private fun NoteCard(
note: NoteEntity,
catPaths: Map<String, String>,
others: Int,
remotelyChanged: Boolean,
onClick: () -> Unit,
) {
Card(onClick = onClick, shape = RoundedCornerShape(18.dp), elevation = CardDefaults.cardElevation(2.dp)) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
(note.title.ifBlank { "Sem título" }) + extOf(note.format),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
maxLines = 1,
)
when {
note.pendingDelete -> StatusDot(Color(0xFFE01B24), "exclusão na fila")
note.dirty -> StatusDot(Color(0xFFE5A50A), "não enviado")
else -> Icon(Icons.Default.CloudDone, "sincronizado",
Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary)
}
if (others > 0) {
Spacer(Modifier.width(6.dp))
Text("👥$others", style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary)
}
}
Text(
ago(note.updatedAt) + if (note.tags.isNotBlank())
" · " + note.tags.split(",").filter { it.isNotBlank() }.joinToString(" ") { "#$it" } else "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
val catList = note.categories.split(",").filter { it.isNotBlank() }
if (catList.isNotEmpty() || remotelyChanged || note.conflictNote.isNotBlank()) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)) {
catList.forEach { id ->
AssistChip(onClick = onClick, label = {
Text("📁 " + (catPaths[id] ?: id), style = MaterialTheme.typography.labelSmall)
})
}
if (remotelyChanged) WarnChip("mudou remotamente")
if (note.conflictNote.isNotBlank()) WarnChip("conflito resolvido")
}
}
}
}
}
@Composable
private fun WarnChip(text: String) {
Surface(color = MaterialTheme.colorScheme.tertiaryContainer, shape = RoundedCornerShape(50)) {
Row(Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Icon(Icons.Default.Warning, null, Modifier.size(12.dp))
Text(text, style = MaterialTheme.typography.labelSmall)
}
}
}
@Composable
private fun StatusDot(color: Color, desc: String) {
Box(
Modifier.size(12.dp),
contentAlignment = Alignment.Center,
) {
Surface(color = color, shape = androidx.compose.foundation.shape.CircleShape,
modifier = Modifier.size(10.dp)) { Text("", Modifier.size(10.dp)) }
}
// descrição para acessibilidade via contentDescription do item pai não é crítica aqui
}
/* ================================================================== Editor */
private enum class EditorMode { Edit, Wysiwyg, Preview }
/** Editor offline-first: autosave local após 10 s de ociosidade + salvar explícito. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditorScreen(vm: NotasViewModel, noteId: String, onBack: () -> Unit) {
var loaded by remember { mutableStateOf(false) }
var title by remember { mutableStateOf("") }
var title by remember { mutableStateOf(TextFieldValue("")) }
var format by remember { mutableStateOf("md") }
var content by remember { mutableStateOf("") }
var body by remember { mutableStateOf(TextFieldValue("")) }
var selectedCats by remember { mutableStateOf<List<String>>(emptyList()) }
var savedAt by remember { mutableStateOf("") }
var mode by remember { mutableStateOf(EditorMode.Edit) }
val cats by vm.categories.collectAsState()
val remoteChanged by vm.remoteChanged.collectAsState()
val presence by vm.presence.collectAsState()
val controller = remember { WysiwygController() }
val keyboard = LocalSoftwareKeyboardController.current
val dark = isSystemInDarkTheme()
LaunchedEffect(noteId) {
vm.noteById(noteId)?.let {
title = it.title
title = TextFieldValue(it.title)
format = it.format
content = it.content
body = TextFieldValue(it.content)
selectedCats = it.categories.split(",").filter { c -> c.isNotBlank() }
}
loaded = true
}
LaunchedEffect(noteId, loaded) { vm.openNoteId = noteId }
androidx.compose.runtime.DisposableEffect(Unit) {
onDispose { vm.openNoteId = null }
}
// SSE: nota limpa aberta mudou no servidor → atualiza o corpo ao vivo
val liveRefresh by vm.editorRefresh.collectAsState()
LaunchedEffect(liveRefresh) {
val r = liveRefresh ?: return@LaunchedEffect
if (r.first == noteId) {
body = TextFieldValue(r.second)
vm.editorRefresh.value = null
}
}
fun persist() {
if (!loaded) return
vm.saveNote(noteId, title, format, selectedCats, content)
savedAt = java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT)
.format(java.util.Date())
vm.saveNote(noteId, title.text, format, selectedCats, body.text)
savedAt = java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT).format(java.util.Date())
}
// ociosidade: o efeito é reiniciado a cada alteração; só grava se
// passarem 10 s sem nenhuma (cada tecla cancela o delay anterior)
LaunchedEffect(title, format, content, loaded) {
if (!loaded) return@LaunchedEffect
// WYSIWYG: ponte JS avisa mudança → Kotlin busca o markdown (debounce)
var changeTick by remember { mutableStateOf(0) }
LaunchedEffect(controller) {
controller.onChanged = { changeTick++ }
}
LaunchedEffect(changeTick) {
if (changeTick == 0) return@LaunchedEffect
delay(900)
controller.requestMd { md ->
if (md != body.text) {
body = TextFieldValue(md)
persist()
}
}
}
// autosave por ociosidade no modo texto
LaunchedEffect(title, body, loaded) {
if (!loaded || mode != EditorMode.Edit) return@LaunchedEffect
delay(AUTOSAVE_IDLE_MS)
persist()
}
// ao entrar em WYSIWYG/Preview, (re)renderiza o html
LaunchedEffect(mode, loaded) {
if (!loaded || mode == EditorMode.Edit) return@LaunchedEffect
if (format == "html") controller.setHtml(body.text)
else vm.renderMarkdown(body.text) { html -> controller.setHtml(html) }
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(title.ifBlank { "Nova nota" }) },
navigationIcon = { TextButton(onClick = { persist(); onBack() }) { Text("←") } },
androidx.compose.material3.TopAppBar(
navigationIcon = {
IconButton(onClick = { persist(); onBack() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Voltar")
}
},
title = {
OutlinedTextField(
value = title, onValueChange = { title = it; },
placeholder = { Text("Título") },
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent, unfocusedBorderColor = Color.Transparent),
modifier = Modifier.width(240.dp),
)
},
actions = {
Text(savedAt.ifBlank { "—" }, style = MaterialTheme.typography.labelSmall)
TextButton(onClick = { persist(); keyboard?.hide() }) { Text("Salvar") }
if ((presence[noteId] ?: 0) > 0) Text("👥", Modifier.padding(end = 6.dp))
Text(
savedAt.ifBlank { if (loaded) "—" else "" },
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
IconButton(onClick = { persist(); keyboard?.hide() }) {
Icon(Icons.Default.CloudDone, "Salvar")
}
},
)
},
) { pad ->
Column(modifier = Modifier.padding(pad).fillMaxSize().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(value = title, onValueChange = { title = it },
label = { Text("Título") }, singleLine = true, modifier = Modifier.fillMaxWidth())
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("txt", "md", "html").forEach { f ->
FilterChip(selected = format == f, onClick = { format = f }, label = { Text(f.uppercase()) })
Column(modifier = Modifier.padding(pad).fillMaxSize()) {
if (remoteChanged.contains(noteId)) {
Surface(color = MaterialTheme.colorScheme.tertiaryContainer,
shape = RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp)) {
Row(Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Mudou no servidor.", style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f))
TextButton(onClick = {
vm.reloadFromServer(noteId) { n ->
n?.let { body = TextFieldValue(it.content) }
}
}) { Text("Recarregar") }
TextButton(onClick = { vm.dismissRemoteChange(noteId) }) { Text("Manter") }
}
}
}
if (cats.isNotEmpty()) {
Text("Categorias", style = MaterialTheme.typography.labelMedium)
// seletor de modo + formato
Row(
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ModeChip(mode == EditorMode.Edit, Icons.Default.Edit, "Editar") { mode = EditorMode.Edit }
if (format != "txt") {
ModeChip(mode == EditorMode.Wysiwyg, Icons.Default.Description, "WYSIWYG") { mode = EditorMode.Wysiwyg }
ModeChip(mode == EditorMode.Preview, Icons.Default.Visibility, "Preview") { mode = EditorMode.Preview }
}
Spacer(Modifier.weight(1f))
listOf("txt", "md", "html").forEach { f ->
FilterChip(selected = format == f, onClick = { format = f },
label = { Text(f.uppercase(), style = MaterialTheme.typography.labelSmall) })
}
}
if (cats.isNotEmpty() && mode == EditorMode.Edit) {
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState())
.padding(horizontal = 12.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
cats.forEach { c ->
cats.take(14).forEach { c ->
FilterChip(
selected = selectedCats.contains(c.id),
onClick = {
selectedCats = if (selectedCats.contains(c.id))
selectedCats - c.id else selectedCats + c.id
},
label = { Text(c.name) },
label = { Text(c.name, style = MaterialTheme.typography.labelSmall) },
)
}
}
}
OutlinedTextField(
value = content, onValueChange = { content = it },
modifier = Modifier.fillMaxWidth().weight(1f),
textStyle = androidx.compose.ui.text.TextStyle(fontFamily = FontFamily.Monospace),
placeholder = { Text("Escreva… #tags viram tags no servidor") },
)
when (mode) {
EditorMode.Edit -> {
OutlinedTextField(
value = body,
onValueChange = { body = it },
placeholder = { Text("Escreva… #tags viram tags no servidor") },
modifier = Modifier.fillMaxWidth().weight(1f).padding(horizontal = 12.dp),
shape = RoundedCornerShape(16.dp),
textStyle = TextStyle(fontFamily = FontFamily.Monospace, fontSize = MaterialTheme.typography.bodyLarge.fontSize),
)
MdToolbar(
enabled = format != "txt",
onCmd = { cmd ->
body = when (cmd) {
"bold" -> wrap(body, "**", "**", "negrito")
"italic" -> wrap(body, "*", "*", "itálico")
"strike" -> wrap(body, "~~", "~~", "tachado")
"code" -> wrap(body, "`", "`", "código")
"h1" -> prefix(body, "# ")
"h2" -> prefix(body, "## ")
"ul" -> prefix(body, "- ")
"quote" -> prefix(body, "> ")
"link" -> wrap(body, "[", "](https://)", "link")
"block" -> wrap(body, "\n```\n", "\n```\n", "código em bloco")
else -> body
}
},
)
}
EditorMode.Wysiwyg -> {
EditorWebView(controller, dark, Modifier.fillMaxWidth().weight(1f))
WysiwygToolbar(controller, format)
}
EditorMode.Preview -> {
EditorWebView(controller, dark, Modifier.fillMaxWidth().weight(1f))
}
}
}
}
}
@Composable
private fun ModeChip(selected: Boolean, icon: ImageVector, label: String, onClick: () -> Unit) {
FilterChip(selected = selected, onClick = onClick,
leadingIcon = { Icon(icon, null, Modifier.size(16.dp)) },
label = { Text(label, style = MaterialTheme.typography.labelMedium) })
}
@Composable
private fun MdToolbar(enabled: Boolean, onCmd: (String) -> Unit) {
val items = listOf(
"B" to "bold", "I" to "italic", "S" to "strike", "H1" to "h1", "H2" to "h2",
"•" to "ul", "❝" to "quote", "🔗" to "link", "</>" to "code", "{ }" to "block",
)
Surface(
tonalElevation = 3.dp,
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
) {
Row(
Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
items.forEach { (label, cmd) ->
TextButton(
onClick = { if (enabled) onCmd(cmd) },
enabled = enabled,
contentPadding = androidx.compose.foundation.layout.PaddingValues(8.dp, 4.dp),
) { Text(label, fontWeight = FontWeight.Bold) }
}
}
}
}
@Composable
private fun WysiwygToolbar(controller: WysiwygController, format: String) {
Surface(tonalElevation = 3.dp, modifier = Modifier.fillMaxWidth()) {
Row(
Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
val cmds: List<Pair<String, () -> Unit>> = if (format == "html") listOf(
"B" to { controller.exec("bold") },
"I" to { controller.exec("italic") },
"S" to { controller.exec("strikeThrough") },
"H1" to { controller.exec("formatBlock", "h1") },
"H2" to { controller.exec("formatBlock", "h2") },
"•" to { controller.exec("insertUnorderedList") },
"1." to { controller.exec("insertOrderedList") },
"❝" to { controller.exec("formatBlock", "blockquote") },
"🔗" to { controller.exec("createLink", "https://") },
"</>" to { controller.exec("insertHTML", "<code>código</code>") },
) else listOf(
"B" to { controller.exec("bold") },
"I" to { controller.exec("italic") },
"S" to { controller.exec("strikeThrough") },
"H1" to { controller.exec("formatBlock", "h1") },
"H2" to { controller.exec("formatBlock", "h2") },
"•" to { controller.exec("insertUnorderedList") },
"1." to { controller.exec("insertOrderedList") },
"❝" to { controller.exec("formatBlock", "blockquote") },
"🔗" to { controller.exec("createLink", "https://") },
"</>" to { controller.exec("insertHTML", "<code>código</code>") },
"{ }" to { controller.exec("insertHTML", "<pre><code>bloco</code></pre>") },
)
cmds.forEach { (label, act) ->
TextButton(onClick = act, contentPadding = androidx.compose.foundation.layout.PaddingValues(8.dp, 4.dp)) {
Text(label, fontWeight = FontWeight.Bold)
}
}
}
}
}
// ---- helpers de markdown no TextField ----
private fun wrap(v: TextFieldValue, before: String, after: String, placeholder: String): TextFieldValue {
val sel = v.selection
val text = if (sel.length > 0) v.text.substring(sel.start, sel.end) else placeholder
val out = v.text.substring(0, sel.start) + before + text + after + v.text.substring(sel.end)
val start = sel.start + before.length
return TextFieldValue(out, TextRange(start, start + text.length))
}
private fun prefix(v: TextFieldValue, prefixStr: String): TextFieldValue {
val start = v.text.lastIndexOf('\n', (v.selection.start - 1).coerceAtLeast(0)) + 1
var end = v.text.indexOf('\n', v.selection.end)
if (end < 0) end = v.text.length
val block = v.text.substring(start, end)
val newBlock = block.split("\n").joinToString("\n") { line ->
if (line.startsWith(prefixStr)) line.removePrefix(prefixStr) else prefixStr + line
}
val out = v.text.substring(0, start) + newBlock + v.text.substring(end)
val caret = start + newBlock.length
return TextFieldValue(out, TextRange(caret, caret))
}