From a8c8a5a998da2ec46325ae0a2d59f8b4daed7152 Mon Sep 17 00:00:00 2001 From: lgcosta Date: Thu, 5 Nov 2026 21:48:16 -0300 Subject: [PATCH] =?UTF-8?q?feat(ui):=20moderniza=C3=A7=C3=A3o=20visual=20M?= =?UTF-8?q?aterial=203=20(v0.3.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 26 +- app/build.gradle.kts | 6 +- .../java/com/labexposed/notas/MainActivity.kt | 50 +--- .../com/labexposed/notas/ui/NoteSearch.kt | 20 ++ .../java/com/labexposed/notas/ui/Screens.kt | 269 ++++++++++++++---- .../com/labexposed/notas/ui/theme/Theme.kt | 23 +- .../com/labexposed/notas/ui/NoteSearchTest.kt | 29 ++ 7 files changed, 296 insertions(+), 127 deletions(-) create mode 100644 app/src/main/java/com/labexposed/notas/ui/NoteSearch.kt create mode 100644 app/src/test/java/com/labexposed/notas/ui/NoteSearchTest.kt diff --git a/README.md b/README.md index 4f8ca8d..169206f 100644 --- a/README.md +++ b/README.md @@ -106,21 +106,12 @@ notas-online-android/ tags/atributos (scripts/`on*` sanitizados). Preview é o mesmo WebView não-editável. Barra de ferramentas aplica `execCommand` (B I S H1 H2 listas ❝ 🔗 código). -## UI (Material 3, dynamic color) +## UI (Material 3, dynamic color, edge-to-edge) — v0.3.0 -- **Lista**: `MediumTopAppBar`, banner de status colorido (Offline/Sincronizando/Sincronizado± - pendências/Erro), chips de filtro (Todas / 📁 categoria com caminho da árvore / #tag), cards - com título+extensão, tempo relativo, tags, chip de categoria(s) e ícone de status por nota - (☁✓ sincronizada, ● âmbar não enviada, 🗑 exclusão na fila, ⚠ conflito/mudança remota, 👥N - presença), **botão de excluir por card** (🗑) com diálogo de confirmação, FAB estendido "Nova". -- **Editor**: campo de título na top bar, seletor de modo (Editar / WYSIWYG / Preview), chips de - formato (TXT/MD/HTML) e de categorias (multi-seleção), corpo monoespaçado com toolbar de - Markdown (B I S H1 H2 • ❝ 🔗 ` { }) que envolve/prefixa a seleção, selo de presença 👥, - horário do último salvamento e **botão de excluir** (com confirmação; volta para a lista). -- **Exclusões sincronizam**: apagar marca `pendingDelete`, entra `delete` na fila e sobe na - próxima sync (nota local-only some direto); outras sessões recebem o evento SSE `deleted` e a - nota some delas também se estiver limpa. Se a nota já sumiu do servidor (404), a fila limpa - sozinha. +- **Lista**: `MediumTopAppBar`, estado de sync compacto com cores M3 e chips acionáveis, busca por título/conteúdo/tags, atualização por gesto, filtros por categoria/tag, cards animados com status e presença acessível, estado vazio com CTA e FAB "Nova". +- **Editor**: campo de título na top bar, seletor de modo (Editar / WYSIWYG / Preview), chips de formato e categorias, corpo monoespaçado com toolbar Markdown, presença com ícone acessível, autosave e exclusão confirmada. +- **Visual**: cores dinâmicas quando disponíveis, tipografia ajustada e edge-to-edge para integrar conteúdo às barras do sistema. +- **Exclusões sincronizam**: apagar marca `pendingDelete`, entra `delete` na fila e sobe na próxima sync; nota local-only some direto. Eventos SSE removem notas limpas das outras sessões. - **Servidor**: URL + token + autor com *Testar conexão* e validação de esquema http(s). ## Compilar e instalar @@ -139,8 +130,11 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk ## Testado em -- **Build**: `assembleRelease` limpo (JDK 21 / AGP 8.7.3 / Kotlin 2.0.21 / SDK 35), APK assinado - com R8 ≈ **1,6 MB** (debug ≈ 17 MB). +- **Build**: `testDebugUnitTest` + `assembleDebug` + `assembleRelease` limpos (JDK 21 / AGP 8.7.3 / + Kotlin 2.0.21 / SDK 35 / Compose BOM 2025.06); release R8 ≈ **1,6 MB**, debug ≈ 17 MB. +- **Emulador Android 15 (x86_64, Pixel 6)** — v0.3.0: edge-to-edge, status de sync compacto, + campo de busca, estado vazio com CTA e editor com chips de modo/formato verificados por + screenshot + uiautomator (config → lista → criar nota → editor). - **Emulador Android 15 (x86_64, Pixel 6)** contra o servidor notas-online real, cenários: - configuração de servidor com *Testar conexão* → OK; - criar nota offline → autosave por ociosidade (10 s) → `POST` único (sem duplicatas); diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e71ab14..86137ca 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { defaultConfig { applicationId = "com.labexposed.notas" minSdk = 26 - versionCode = 4 - versionName = "0.2.2" + versionCode = 5 + versionName = "0.3.0" } buildTypes { @@ -41,7 +41,7 @@ android { } dependencies { - val composeBom = platform("androidx.compose:compose-bom:2024.12.01") + val composeBom = platform("androidx.compose:compose-bom:2025.06.00") implementation(composeBom) // UI diff --git a/app/src/main/java/com/labexposed/notas/MainActivity.kt b/app/src/main/java/com/labexposed/notas/MainActivity.kt index ec9ed82..1e36689 100644 --- a/app/src/main/java/com/labexposed/notas/MainActivity.kt +++ b/app/src/main/java/com/labexposed/notas/MainActivity.kt @@ -3,30 +3,16 @@ package com.labexposed.notas import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text +import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.compose.LifecycleEventEffect import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.lifecycle.Lifecycle -import com.labexposed.notas.domain.SyncPhase -import com.labexposed.notas.domain.SyncUiState import com.labexposed.notas.ui.EditorScreen import com.labexposed.notas.ui.NotesListScreen import com.labexposed.notas.ui.NotasViewModel @@ -36,6 +22,7 @@ import com.labexposed.notas.ui.theme.NotasTheme class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + enableEdgeToEdge() setContent { NotasTheme { NotasApp_() } } @@ -68,36 +55,3 @@ private fun NotasApp_(vm: NotasViewModel = viewModel()) { } } } - -/** Banner de status: Offline · Sincronizando · Sincronizado · Erro · pendências. */ -@Composable -fun SyncBanner(state: SyncUiState, modifier: Modifier = Modifier) { - val (bg, fg, label) = when (state.phase) { - SyncPhase.NotConfigured -> Quinteto(Color(0xFF9A9A9A), Color.White, "Configure o servidor ⚙") - SyncPhase.Offline -> Quinteto(Color(0xFF6F6F6F), Color.White, "Offline — ${state.pendingCount} alteração(ões) na fila") - SyncPhase.Syncing -> Quinteto(Color(0xFF3584E4), Color.White, "Sincronizando…") - SyncPhase.Synced -> { - val pend = if (state.pendingCount > 0) " · ${state.pendingCount} pendente(s)" else "" - val conf = if (state.conflicts > 0) " · ⚠ ${state.conflicts} conflito(s)" else "" - val hora = java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT) - .format(java.util.Date(state.lastSyncAt)) - Quinteto(Color(0xFF2EC27E), Color(0xFF073042), "Sincronizado às $hora$pend$conf") - } - SyncPhase.Error -> Quinteto(Color(0xFFE01B24), Color.White, "Erro: ${state.message}") - SyncPhase.Unauthorized -> Quinteto(Color(0xFFE01B24), Color.White, "Token inválido — revise em Servidor") - } - Surface(color = bg, shape = RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) { - Row( - modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text(label, color = fg, style = MaterialTheme.typography.labelLarge) - if (state.dirtyCount > 0 && state.phase == SyncPhase.Synced) { - Text("● ${state.dirtyCount} não enviado(s)", color = fg, style = MaterialTheme.typography.labelMedium) - } - } - } -} - -private data class Quinteto(val bg: Color, val fg: Color, val label: String) diff --git a/app/src/main/java/com/labexposed/notas/ui/NoteSearch.kt b/app/src/main/java/com/labexposed/notas/ui/NoteSearch.kt new file mode 100644 index 0000000..cf883f3 --- /dev/null +++ b/app/src/main/java/com/labexposed/notas/ui/NoteSearch.kt @@ -0,0 +1,20 @@ +package com.labexposed.notas.ui + +import com.labexposed.notas.data.local.NoteEntity + +internal fun filterNotes( + notes: List, + query: String, + categoryId: String? = null, + tag: String? = null, +): List { + val normalizedQuery = query.trim() + return notes.filter { note -> + (categoryId == null || note.categories.split(",").contains(categoryId)) && + (tag == null || note.tags.split(",").contains(tag)) && + (normalizedQuery.isEmpty() || + note.title.contains(normalizedQuery, ignoreCase = true) || + note.content.contains(normalizedQuery, ignoreCase = true) || + note.tags.split(",").any { it.contains(normalizedQuery, ignoreCase = true) }) + } +} diff --git a/app/src/main/java/com/labexposed/notas/ui/Screens.kt b/app/src/main/java/com/labexposed/notas/ui/Screens.kt index 76c2b74..a54754b 100644 --- a/app/src/main/java/com/labexposed/notas/ui/Screens.kt +++ b/app/src/main/java/com/labexposed/notas/ui/Screens.kt @@ -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(null) } var filterTag by remember { mutableStateOf(null) } + var query by remember { mutableStateOf("") } var deleteTarget by remember { mutableStateOf(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, diff --git a/app/src/main/java/com/labexposed/notas/ui/theme/Theme.kt b/app/src/main/java/com/labexposed/notas/ui/theme/Theme.kt index ca093ee..e25d2c5 100644 --- a/app/src/main/java/com/labexposed/notas/ui/theme/Theme.kt +++ b/app/src/main/java/com/labexposed/notas/ui/theme/Theme.kt @@ -3,6 +3,7 @@ package com.labexposed.notas.ui.theme import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme @@ -10,8 +11,12 @@ import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp -// seed Greybird/Mousepad-like +// seed Greybird/Mousepad-like (fallback quando dynamic color indisponível) private val Light = lightColorScheme( primary = Color(0xFF1C71D8), secondary = Color(0xFF3584E4), @@ -24,6 +29,16 @@ private val Dark = darkColorScheme( secondary = Color(0xFF3584E4), ) +private val NotasTypography = Typography( + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp, + ), +) + @Composable fun NotasTheme(content: @Composable () -> Unit) { val ctx = LocalContext.current @@ -34,5 +49,9 @@ fun NotasTheme(content: @Composable () -> Unit) { isSystemInDarkTheme() -> Dark else -> Light } - MaterialTheme(colorScheme = scheme, content = content) + MaterialTheme( + colorScheme = scheme, + typography = NotasTypography, + content = content, + ) } diff --git a/app/src/test/java/com/labexposed/notas/ui/NoteSearchTest.kt b/app/src/test/java/com/labexposed/notas/ui/NoteSearchTest.kt new file mode 100644 index 0000000..aa0cc1f --- /dev/null +++ b/app/src/test/java/com/labexposed/notas/ui/NoteSearchTest.kt @@ -0,0 +1,29 @@ +package com.labexposed.notas.ui + +import com.labexposed.notas.data.local.NoteEntity +import org.junit.Assert.assertEquals +import org.junit.Test + +class NoteSearchTest { + @Test + fun queryMatchesTitleOrContentWithoutCaseSensitivity() { + val notes = listOf( + note("1", title = "Plano semanal", content = "rascunho"), + note("2", title = "Diário", content = "Revisar PLANO amanhã"), + note("3", title = "Compras", content = "leite e pão"), + ) + + val result = filterNotes(notes, query = "plano") + + assertEquals(listOf("1", "2"), result.map { it.id }) + } + + private fun note(id: String, title: String, content: String) = NoteEntity( + id = id, + title = title, + format = "md", + content = content, + createdAt = 1L, + updatedAt = 1L, + ) +}