Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8c8a5a998 |
No files matched your search
@@ -106,21 +106,12 @@ notas-online-android/
|
|||||||
tags/atributos (scripts/`on*` sanitizados). Preview é o mesmo WebView não-editável. Barra de
|
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).
|
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±
|
- **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".
|
||||||
pendências/Erro), chips de filtro (Todas / 📁 categoria com caminho da árvore / #tag), cards
|
- **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.
|
||||||
com título+extensão, tempo relativo, tags, chip de categoria(s) e ícone de status por nota
|
- **Visual**: cores dinâmicas quando disponíveis, tipografia ajustada e edge-to-edge para integrar conteúdo às barras do sistema.
|
||||||
(☁✓ sincronizada, ● âmbar não enviada, 🗑 exclusão na fila, ⚠ conflito/mudança remota, 👥N
|
- **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.
|
||||||
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.
|
|
||||||
- **Servidor**: URL + token + autor com *Testar conexão* e validação de esquema http(s).
|
- **Servidor**: URL + token + autor com *Testar conexão* e validação de esquema http(s).
|
||||||
|
|
||||||
## Compilar e instalar
|
## Compilar e instalar
|
||||||
@@ -139,8 +130,11 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk
|
|||||||
|
|
||||||
## Testado em
|
## Testado em
|
||||||
|
|
||||||
- **Build**: `assembleRelease` limpo (JDK 21 / AGP 8.7.3 / Kotlin 2.0.21 / SDK 35), APK assinado
|
- **Build**: `testDebugUnitTest` + `assembleDebug` + `assembleRelease` limpos (JDK 21 / AGP 8.7.3 /
|
||||||
com R8 ≈ **1,6 MB** (debug ≈ 17 MB).
|
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:
|
- **Emulador Android 15 (x86_64, Pixel 6)** contra o servidor notas-online real, cenários:
|
||||||
- configuração de servidor com *Testar conexão* → OK;
|
- configuração de servidor com *Testar conexão* → OK;
|
||||||
- criar nota offline → autosave por ociosidade (10 s) → `POST` único (sem duplicatas);
|
- criar nota offline → autosave por ociosidade (10 s) → `POST` único (sem duplicatas);
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ android {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.labexposed.notas"
|
applicationId = "com.labexposed.notas"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
versionCode = 4
|
versionCode = 5
|
||||||
versionName = "0.2.2"
|
versionName = "0.3.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -41,7 +41,7 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
|
val composeBom = platform("androidx.compose:compose-bom:2025.06.00")
|
||||||
implementation(composeBom)
|
implementation(composeBom)
|
||||||
|
|
||||||
// UI
|
// UI
|
||||||
|
|||||||
@@ -3,30 +3,16 @@ package com.labexposed.notas
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.activity.enableEdgeToEdge
|
||||||
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.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
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.viewmodel.compose.viewModel
|
||||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.lifecycle.Lifecycle
|
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.EditorScreen
|
||||||
import com.labexposed.notas.ui.NotesListScreen
|
import com.labexposed.notas.ui.NotesListScreen
|
||||||
import com.labexposed.notas.ui.NotasViewModel
|
import com.labexposed.notas.ui.NotasViewModel
|
||||||
@@ -36,6 +22,7 @@ import com.labexposed.notas.ui.theme.NotasTheme
|
|||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
NotasTheme { NotasApp_() }
|
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
|
package com.labexposed.notas.ui
|
||||||
|
|
||||||
import com.labexposed.notas.SyncBanner
|
import com.labexposed.notas.domain.Settings as ServerSettings
|
||||||
import com.labexposed.notas.domain.Settings
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.animation.animateColorAsState
|
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
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.CloudOff
|
||||||
import androidx.compose.material.icons.filled.CloudSync
|
import androidx.compose.material.icons.filled.CloudSync
|
||||||
import androidx.compose.material.icons.filled.Delete
|
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.Description
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material.icons.filled.Visibility
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
import androidx.compose.material.icons.filled.Warning
|
import androidx.compose.material.icons.filled.Warning
|
||||||
import androidx.compose.material3.AssistChip
|
import androidx.compose.material3.AssistChip
|
||||||
|
import androidx.compose.material3.AssistChipDefaults
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ElevatedAssistChip
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -52,6 +57,7 @@ import androidx.compose.material3.OutlinedTextFieldDefaults
|
|||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@@ -162,14 +168,14 @@ fun SettingsScreen(vm: NotasViewModel, onDone: () -> Unit) {
|
|||||||
}
|
}
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
TextButton(onClick = {
|
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") }
|
}) { Text("Testar conexão") }
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
androidx.compose.material3.Button(
|
androidx.compose.material3.Button(
|
||||||
enabled = url.text.isNotBlank() && token.text.isNotBlank() &&
|
enabled = url.text.isNotBlank() && token.text.isNotBlank() &&
|
||||||
(url.text.startsWith("http://") || url.text.startsWith("https://")),
|
(url.text.startsWith("http://") || url.text.startsWith("https://")),
|
||||||
onClick = {
|
onClick = {
|
||||||
vm.saveSettings(Settings(url.text, token.text, author.text))
|
vm.saveSettings(ServerSettings(url.text, token.text, author.text))
|
||||||
onDone()
|
onDone()
|
||||||
},
|
},
|
||||||
shape = RoundedCornerShape(14.dp),
|
shape = RoundedCornerShape(14.dp),
|
||||||
@@ -196,10 +202,10 @@ fun NotesListScreen(
|
|||||||
val remoteChanged by vm.remoteChanged.collectAsState()
|
val remoteChanged by vm.remoteChanged.collectAsState()
|
||||||
var filterCat by remember { mutableStateOf<String?>(null) }
|
var filterCat by remember { mutableStateOf<String?>(null) }
|
||||||
var filterTag by remember { mutableStateOf<String?>(null) }
|
var filterTag by remember { mutableStateOf<String?>(null) }
|
||||||
|
var query by remember { mutableStateOf("") }
|
||||||
var deleteTarget by remember { mutableStateOf<NoteEntity?>(null) }
|
var deleteTarget by remember { mutableStateOf<NoteEntity?>(null) }
|
||||||
val snackbar = remember { SnackbarHostState() }
|
val snackbar = remember { SnackbarHostState() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
deleteTarget?.let { target ->
|
deleteTarget?.let { target ->
|
||||||
androidx.compose.material3.AlertDialog(
|
androidx.compose.material3.AlertDialog(
|
||||||
onDismissRequest = { deleteTarget = null },
|
onDismissRequest = { deleteTarget = null },
|
||||||
@@ -240,13 +246,9 @@ fun NotesListScreen(
|
|||||||
val allTags = remember(notes) {
|
val allTags = remember(notes) {
|
||||||
notes.flatMap { it.tags.split(",").filter { t -> t.isNotBlank() } }.distinct().sorted()
|
notes.flatMap { it.tags.split(",").filter { t -> t.isNotBlank() } }.distinct().sorted()
|
||||||
}
|
}
|
||||||
val shown = remember(notes, filterCat, filterTag) {
|
val shown = remember(notes, filterCat, filterTag, query) {
|
||||||
notes.filter { n ->
|
filterNotes(notes, query, filterCat, filterTag)
|
||||||
(filterCat == null || n.categories.split(",").contains(filterCat)) &&
|
|
||||||
(filterTag == null || n.tags.split(",").contains(filterTag))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val events by vm.events.collectAsState()
|
val events by vm.events.collectAsState()
|
||||||
LaunchedEffect(events) {
|
LaunchedEffect(events) {
|
||||||
if (events is ListEvent.RemoteChange) {
|
if (events is ListEvent.RemoteChange) {
|
||||||
@@ -254,7 +256,6 @@ fun NotesListScreen(
|
|||||||
vm.consumeEvent()
|
vm.consumeEvent()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
snackbarHost = { SnackbarHost(snackbar) },
|
snackbarHost = { SnackbarHost(snackbar) },
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -285,42 +286,76 @@ fun NotesListScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { pad ->
|
) { pad ->
|
||||||
Column(modifier = Modifier.padding(pad).fillMaxSize()) {
|
PullToRefreshBox(
|
||||||
SyncBanner(sync, Modifier.padding(horizontal = 12.dp))
|
isRefreshing = sync.phase == SyncPhase.Syncing,
|
||||||
if (cats.isNotEmpty() || allTags.isNotEmpty()) {
|
onRefresh = { vm.syncNow() },
|
||||||
Row(
|
modifier = Modifier.padding(pad).fillMaxSize(),
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(start = 12.dp, end = 12.dp, bottom = 96.dp),
|
contentPadding = PaddingValues(start = 12.dp, end = 12.dp, bottom = 96.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.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(
|
NoteCard(
|
||||||
note = note,
|
note = note,
|
||||||
catPaths = catPaths,
|
catPaths = catPaths,
|
||||||
@@ -328,6 +363,7 @@ fun NotesListScreen(
|
|||||||
remotelyChanged = remoteChanged.contains(note.id),
|
remotelyChanged = remoteChanged.contains(note.id),
|
||||||
onClick = { onOpen(note.id) },
|
onClick = { onOpen(note.id) },
|
||||||
onDelete = { deleteTarget = note },
|
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)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun NoteCard(
|
private fun NoteCard(
|
||||||
@@ -344,9 +474,10 @@ private fun NoteCard(
|
|||||||
remotelyChanged: Boolean,
|
remotelyChanged: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Card(onClick = onClick, shape = RoundedCornerShape(18.dp), elevation = CardDefaults.cardElevation(2.dp)) {
|
Card(onClick = onClick, modifier = modifier, shape = RoundedCornerShape(20.dp)) {
|
||||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
(note.title.ifBlank { "Sem título" }) + extOf(note.format),
|
(note.title.ifBlank { "Sem título" }) + extOf(note.format),
|
||||||
@@ -355,18 +486,24 @@ private fun NoteCard(
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
when {
|
when {
|
||||||
note.pendingDelete -> StatusDot(Color(0xFFE01B24), "exclusão na fila")
|
note.pendingDelete -> StatusDot(MaterialTheme.colorScheme.error, "exclusão na fila")
|
||||||
note.dirty -> StatusDot(Color(0xFFE5A50A), "não enviado")
|
note.dirty -> StatusDot(MaterialTheme.colorScheme.tertiary, "não enviado")
|
||||||
else -> Icon(Icons.Default.CloudDone, "sincronizado",
|
else -> Icon(Icons.Default.CloudDone, "sincronizado",
|
||||||
Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary)
|
Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary)
|
||||||
}
|
}
|
||||||
if (others > 0) {
|
if (others > 0) {
|
||||||
Spacer(Modifier.width(6.dp))
|
Spacer(Modifier.width(6.dp))
|
||||||
Text("👥$others", style = MaterialTheme.typography.labelSmall,
|
AssistChip(
|
||||||
color = MaterialTheme.colorScheme.primary)
|
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)) {
|
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)
|
tint = MaterialTheme.colorScheme.outline)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -382,9 +519,16 @@ private fun NoteCard(
|
|||||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp),
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
catList.forEach { id ->
|
catList.forEach { id ->
|
||||||
AssistChip(onClick = onClick, label = {
|
AssistChip(
|
||||||
Text("📁 " + (catPaths[id] ?: id), style = MaterialTheme.typography.labelSmall)
|
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 (remotelyChanged) WarnChip("mudou remotamente")
|
||||||
if (note.conflictNote.isNotBlank()) WarnChip("conflito resolvido")
|
if (note.conflictNote.isNotBlank()) WarnChip("conflito resolvido")
|
||||||
@@ -539,7 +683,16 @@ fun EditorScreen(vm: NotasViewModel, noteId: String, onBack: () -> Unit) {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
actions = {
|
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(
|
Text(
|
||||||
savedAt.ifBlank { if (loaded) "—" else "" },
|
savedAt.ifBlank { if (loaded) "—" else "" },
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.labexposed.notas.ui.theme
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
import androidx.compose.material3.dynamicDarkColorScheme
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
@@ -10,8 +11,12 @@ import androidx.compose.material3.lightColorScheme
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
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(
|
private val Light = lightColorScheme(
|
||||||
primary = Color(0xFF1C71D8),
|
primary = Color(0xFF1C71D8),
|
||||||
secondary = Color(0xFF3584E4),
|
secondary = Color(0xFF3584E4),
|
||||||
@@ -24,6 +29,16 @@ private val Dark = darkColorScheme(
|
|||||||
secondary = Color(0xFF3584E4),
|
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
|
@Composable
|
||||||
fun NotasTheme(content: @Composable () -> Unit) {
|
fun NotasTheme(content: @Composable () -> Unit) {
|
||||||
val ctx = LocalContext.current
|
val ctx = LocalContext.current
|
||||||
@@ -34,5 +49,9 @@ fun NotasTheme(content: @Composable () -> Unit) {
|
|||||||
isSystemInDarkTheme() -> Dark
|
isSystemInDarkTheme() -> Dark
|
||||||
else -> Light
|
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