Files
notas-online-android/app/src/main/java/com/labexposed/notas/ui/Screens.kt
T
lgcosta a8c8a5a998 feat(ui): modernização visual Material 3 (v0.3.0)
- Edge-to-edge (enableEdgeToEdge) com Scaffold/insets em todas as telas
- Compose BOM 2024.12 -> 2025.06; tipografia e tema com dynamic color
- SyncBanner colorido removido -> SyncStatusRow compacto com chips M3
  acionáveis (sync agora / abrir config / erro em errorContainer)
- Lista: busca por título/conteúdo/tag (filterNotes testado),
  pull-to-refresh, animateItem, contentType, estado vazio com CTA
- Ícones no lugar de emoji (pasta, presença, exclusão, aviso)
- Editor: chip de presença acessível; imports limpos
- Teste unitário de busca (NoteSearchTest)
- versionCode 5 / versionName 0.3.0
2026-11-05 21:48:16 -03:00

899 lines
41 KiB
Kotlin

package com.labexposed.notas.ui
import com.labexposed.notas.domain.Settings as ServerSettings
import androidx.compose.material3.ExperimentalMaterial3Api
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.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.Delete
import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Group
import androidx.compose.material.icons.automirrored.filled.NoteAdd
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
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.AssistChipDefaults
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
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.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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
import androidx.compose.runtime.getValue
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.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
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(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 = "✓ Conectado — ${e.notes} nota(s) no servidor."
is ListEvent.ConnectionFail -> feedback = "✗ ${e.message}"
is ListEvent.RemoteChange -> {}
null -> {}
}
if (events != null) vm.consumeEvent()
}
Scaffold(topBar = { MediumTopAppBar(title = { Text("Servidor") }) }) { pad ->
Column(
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") },
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, 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(), shape = RoundedCornerShape(14.dp),
)
if (feedback.isNotEmpty()) {
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(10.dp)) {
TextButton(onClick = {
scope.launch { vm.testConnection(ServerSettings(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(ServerSettings(url.text, token.text, author.text))
onDone()
},
shape = RoundedCornerShape(14.dp),
) { Text("Salvar e continuar") }
}
}
}
}
/* ==================================================================== Lista */
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun NotesListScreen(
vm: NotasViewModel,
onOpen: (String) -> Unit,
onNew: () -> Unit,
onSettings: () -> Unit,
) {
val notes by vm.notes.collectAsState()
val sync by vm.syncState.collectAsState()
val cats by vm.categories.collectAsState()
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) }
var query by remember { mutableStateOf("") }
var deleteTarget by remember { mutableStateOf<NoteEntity?>(null) }
val snackbar = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
deleteTarget?.let { target ->
androidx.compose.material3.AlertDialog(
onDismissRequest = { deleteTarget = null },
icon = { Icon(Icons.Default.Delete, null) },
title = { Text("Excluir nota?") },
text = {
Text("“${target.title.ifBlank { "Sem título" }}” será removida deste aparelho " +
"e do servidor na próxima sincronização (o histórico dela fica nas revisões " +
"do servidor até a exclusão subir). Esta ação não pode ser desfeita pelo app.")
},
confirmButton = {
TextButton(onClick = {
vm.deleteNote(target.id)
deleteTarget = null
scope.launch { snackbar.showSnackbar("Exclusão registrada — sincronizando") }
}) { Text("Excluir", color = MaterialTheme.colorScheme.error) }
},
dismissButton = {
TextButton(onClick = { deleteTarget = null }) { Text("Cancelar") }
},
)
}
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, query) {
filterNotes(notes, query, filterCat, 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 = {
MediumTopAppBar(
title = { Text("Notas") },
actions = {
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 = {
ExtendedFloatingActionButton(
onClick = onNew,
icon = { Icon(Icons.Default.Add, null) },
text = { Text("Nova") },
)
},
) { pad ->
PullToRefreshBox(
isRefreshing = sync.phase == SyncPhase.Syncing,
onRefresh = { vm.syncNow() },
modifier = Modifier.padding(pad).fillMaxSize(),
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 12.dp, end = 12.dp, bottom = 96.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
SyncStatusRow(
state = sync,
onSyncNow = { vm.syncNow() },
onSettings = onSettings,
modifier = Modifier.padding(vertical = 4.dp),
)
}
item {
OutlinedTextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("Buscar notas") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(onClick = { query = "" }) {
Icon(Icons.Default.Clear, contentDescription = "Limpar busca")
}
}
},
singleLine = true,
shape = RoundedCornerShape(24.dp),
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
)
}
if (cats.isNotEmpty() || allTags.isNotEmpty()) {
item {
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState())
.padding(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) },
leadingIcon = if (filterCat == c.id) null else ({
Icon(Icons.Default.Folder, contentDescription = null,
Modifier.size(16.dp))
}))
}
allTags.take(12).forEach { t ->
FilterChip(filterTag == t, { filterTag = t; filterCat = null },
label = { Text("#$t") })
}
}
}
}
if (shown.isEmpty()) {
item {
EmptyState(
hasNotes = notes.isNotEmpty(),
onNew = onNew,
modifier = Modifier.fillMaxWidth().padding(vertical = 48.dp),
)
}
}
items(shown, key = { it.id }, contentType = { it.format }) { note ->
NoteCard(
note = note,
catPaths = catPaths,
others = presence[note.id] ?: 0,
remotelyChanged = remoteChanged.contains(note.id),
onClick = { onOpen(note.id) },
onDelete = { deleteTarget = note },
modifier = Modifier.animateItem(),
)
}
}
}
}
}
/** Linha de status M3: chip compacto + progresso, sem banner colorido full-width. */
@Composable
private fun SyncStatusRow(
state: SyncUiState,
onSyncNow: () -> Unit,
onSettings: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
when (state.phase) {
SyncPhase.Syncing -> {
CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
Text("Sincronizando…", style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
SyncPhase.Offline -> AssistChip(
onClick = onSyncNow,
label = { Text("Offline · ${state.pendingCount} na fila") },
leadingIcon = { Icon(Icons.Default.CloudOff, null, Modifier.size(16.dp)) },
)
SyncPhase.Error, SyncPhase.Unauthorized -> AssistChip(
onClick = if (state.phase == SyncPhase.Unauthorized) onSettings else onSyncNow,
label = { Text(if (state.phase == SyncPhase.Unauthorized) "Token inválido" else "Erro: ${state.message}") },
leadingIcon = { Icon(Icons.Default.Warning, null, Modifier.size(16.dp)) },
colors = AssistChipDefaults.assistChipColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
labelColor = MaterialTheme.colorScheme.onErrorContainer,
leadingIconContentColor = MaterialTheme.colorScheme.onErrorContainer,
),
)
SyncPhase.NotConfigured -> AssistChip(
onClick = onSettings,
label = { Text("Configure o servidor") },
leadingIcon = { Icon(Icons.Default.Settings, null, Modifier.size(16.dp)) },
)
SyncPhase.Synced -> {
if (state.pendingCount > 0 || state.dirtyCount > 0) {
AssistChip(
onClick = onSyncNow,
label = { Text("${state.pendingCount + state.dirtyCount} pendente(s)") },
leadingIcon = { Icon(Icons.Default.CloudSync, null, Modifier.size(16.dp)) },
)
} else {
val time = java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT)
.format(java.util.Date(state.lastSyncAt))
Text("Sincronizado às $time", style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (state.conflicts > 0) AssistChip(
onClick = onSyncNow,
label = { Text("${state.conflicts} conflito(s)") },
leadingIcon = { Icon(Icons.Default.Warning, null, Modifier.size(16.dp)) },
colors = AssistChipDefaults.assistChipColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
labelColor = MaterialTheme.colorScheme.onTertiaryContainer,
leadingIconContentColor = MaterialTheme.colorScheme.onTertiaryContainer,
),
)
}
}
}
}
@Composable
private fun EmptyState(hasNotes: Boolean, onNew: () -> Unit, modifier: Modifier = Modifier) {
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)) {
Icon(Icons.AutoMirrored.Filled.NoteAdd, contentDescription = null, Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.surfaceVariant)
Text(
if (!hasNotes) "Nenhuma nota ainda" else "Nada com esse filtro",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
if (!hasNotes) "Toque em Nova — funciona offline."
else "Tente outra busca ou filtro.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (!hasNotes) {
Button(onClick = onNew) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Nova nota")
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
private fun NoteCard(
note: NoteEntity,
catPaths: Map<String, String>,
others: Int,
remotelyChanged: Boolean,
onClick: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(onClick = onClick, modifier = modifier, shape = RoundedCornerShape(20.dp)) {
Column(Modifier.padding(16.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(MaterialTheme.colorScheme.error, "exclusão na fila")
note.dirty -> StatusDot(MaterialTheme.colorScheme.tertiary, "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))
AssistChip(
onClick = onClick,
label = { Text("$others", style = MaterialTheme.typography.labelSmall) },
leadingIcon = {
Icon(Icons.Default.Group, contentDescription = "$others pessoas visualizando",
Modifier.size(14.dp))
},
)
}
IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.DeleteOutline, "Excluir", Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.outline)
}
}
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)
},
leadingIcon = {
Icon(Icons.Default.Folder, contentDescription = null,
Modifier.size(14.dp))
},
)
}
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 }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditorScreen(vm: NotasViewModel, noteId: String, onBack: () -> Unit) {
var loaded by remember { mutableStateOf(false) }
var title by remember { mutableStateOf(TextFieldValue("")) }
var format by remember { mutableStateOf("md") }
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) }
var confirmDelete by remember { mutableStateOf(false) }
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()
if (confirmDelete) {
androidx.compose.material3.AlertDialog(
onDismissRequest = { confirmDelete = false },
icon = { Icon(Icons.Default.Delete, null) },
title = { Text("Excluir nota?") },
text = {
Text("“${title.text.ifBlank { "Sem título" }}” será removida deste aparelho e do " +
"servidor na próxima sincronização. Esta ação não pode ser desfeita pelo app.")
},
confirmButton = {
TextButton(onClick = {
confirmDelete = false
vm.deleteNote(noteId)
onBack()
}) { Text("Excluir", color = MaterialTheme.colorScheme.error) }
},
dismissButton = {
TextButton(onClick = { confirmDelete = false }) { Text("Cancelar") }
},
)
}
LaunchedEffect(noteId) {
vm.noteById(noteId)?.let {
title = TextFieldValue(it.title)
format = it.format
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.text, format, selectedCats, body.text)
savedAt = java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT).format(java.util.Date())
}
// 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 = {
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 = {
if ((presence[noteId] ?: 0) > 0) {
AssistChip(
onClick = {},
label = { Text("${presence[noteId]}") },
leadingIcon = {
Icon(Icons.Default.Group, contentDescription = "Pessoas nesta nota",
Modifier.size(16.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")
}
IconButton(onClick = { confirmDelete = true }) {
Icon(Icons.Default.Delete, "Excluir", tint = MaterialTheme.colorScheme.outline)
}
},
)
},
) { pad ->
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") }
}
}
}
// 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.fillMaxWidth().horizontalScroll(rememberScrollState())
.padding(horizontal = 12.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
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, style = MaterialTheme.typography.labelSmall) },
)
}
}
}
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))
}