- 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
899 lines
41 KiB
Kotlin
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))
|
|
}
|