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:
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))
|
||||
}
|
||||
Reference in new issue
Block a user