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
This commit is contained in:
lgcosta committed 2026-11-05 21:48:16 -03:00
1 parent 8561ae2f8b
commit a8c8a5a998
7 files changed
+296 -127

No files matched your search

@@ -1,8 +1,7 @@
package com.labexposed.notas.ui
import com.labexposed.notas.SyncBanner
import com.labexposed.notas.domain.Settings
import androidx.compose.animation.animateColorAsState
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
@@ -31,16 +30,22 @@ 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.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
@@ -52,6 +57,7 @@ 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
@@ -162,14 +168,14 @@ fun SettingsScreen(vm: NotasViewModel, onDone: () -> Unit) {
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
TextButton(onClick = {
scope.launch { vm.testConnection(Settings(url.text, token.text, author.text)) }
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(Settings(url.text, token.text, author.text))
vm.saveSettings(ServerSettings(url.text, token.text, author.text))
onDone()
},
shape = RoundedCornerShape(14.dp),
@@ -196,10 +202,10 @@ fun NotesListScreen(
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 },
@@ -240,13 +246,9 @@ fun NotesListScreen(
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 shown = remember(notes, filterCat, filterTag, query) {
filterNotes(notes, query, filterCat, filterTag)
}
val events by vm.events.collectAsState()
LaunchedEffect(events) {
if (events is ListEvent.RemoteChange) {
@@ -254,7 +256,6 @@ fun NotesListScreen(
vm.consumeEvent()
}
}
Scaffold(
snackbarHost = { SnackbarHost(snackbar) },
topBar = {
@@ -285,42 +286,76 @@ fun NotesListScreen(
)
},
) { pad ->
Column(modifier = Modifier.padding(pad).fillMaxSize()) {
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,
)
}
}
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),
) {
items(shown, key = { it.id }) { note ->
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,
@@ -328,6 +363,7 @@ fun NotesListScreen(
remotelyChanged = remoteChanged.contains(note.id),
onClick = { onOpen(note.id) },
onDelete = { deleteTarget = note },
modifier = Modifier.animateItem(),
)
}
}
@@ -335,6 +371,100 @@ fun NotesListScreen(
}
}
/** 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(
@@ -344,9 +474,10 @@ private fun NoteCard(
remotelyChanged: Boolean,
onClick: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(onClick = onClick, shape = RoundedCornerShape(18.dp), elevation = CardDefaults.cardElevation(2.dp)) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
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),
@@ -355,18 +486,24 @@ private fun NoteCard(
maxLines = 1,
)
when {
note.pendingDelete -> StatusDot(Color(0xFFE01B24), "exclusão na fila")
note.dirty -> StatusDot(Color(0xFFE5A50A), "não enviado")
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))
Text("👥$others", style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary)
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.Delete, "Excluir", Modifier.size(18.dp),
Icon(Icons.Default.DeleteOutline, "Excluir", Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.outline)
}
}
@@ -382,9 +519,16 @@ private fun NoteCard(
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)
})
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")
@@ -539,7 +683,16 @@ fun EditorScreen(vm: NotasViewModel, noteId: String, onBack: () -> Unit) {
)
},
actions = {
if ((presence[noteId] ?: 0) > 0) Text("👥", Modifier.padding(end = 6.dp))
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,