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:
1 parent
8561ae2f8b
commit
a8c8a5a998
7 files changed
+296
-127
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.labexposed.notas.ui
|
||||
|
||||
import com.labexposed.notas.data.local.NoteEntity
|
||||
|
||||
internal fun filterNotes(
|
||||
notes: List<NoteEntity>,
|
||||
query: String,
|
||||
categoryId: String? = null,
|
||||
tag: String? = null,
|
||||
): List<NoteEntity> {
|
||||
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) })
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user