6 Commits
Author SHA1 Message Date
lgcosta cccba68036 chore(release): bump v0.4.1 (versionCode 8)
fix(sync) 5xx -> Transient com retry; runSync trata Failed/NotConfigured;
SSE multilinha; ApiClient sem imports mortos.

Verificado: testDebugUnitTest + assembleDebug + assembleRelease limpos
(JDK 21 / AGP 8.13.2 / SDK 36).
2026-11-06 10:38:08 -03:00
lgcosta d10853bcaa fix(sync): erros 5xx viram Transient com retry; SSE multilinha; trata Failed/NotConfigured
- SyncOutcome.Transient para 5xx (push/pull/serverOutcome); worker da retry
- runSync: Failed -> Error, NotConfigured respeitado (evita cast crash)
- SseClient: junta linhas data: com \n (spec SSE)
- ApiClient: remove imports sem uso
2026-11-06 10:33:43 -03:00
lgcosta e608493c81 docs: roadmap — Expressive completo aguarda material3 1.5 estável 2026-11-06 08:51:32 -03:00
lgcosta 81e945b490 build: toolchain AGP 8.13.2 + Kotlin 2.2.20 + SDK 36 (v0.4.0)
- Gradle wrapper 8.11.1 -> 8.13 (exigido pelo AGP 8.13)
- AGP 8.7.3 -> 8.13.2; Kotlin 2.0.21 -> 2.2.20; KSP -> 2.2.20-2.0.4
- compileSdk/targetSdk 35 -> 36 (Android 16 como alvo real)
- Compose BOM 2025.06 -> 2026.06 (material3 1.4.0 estável; Expressive
  completo segue beta em 1.5.x e fica de fora a pedido: só estável)
- activity-compose 1.10.1, navigation-compose 2.9.0, lifecycle 2.9.0
- Room 2.6.1 -> 2.8.3; WorkManager 2.9.1 -> 2.10.1; DataStore 1.1.7
- kotlinx-serialization 1.8.1; coroutines 1.10.2
- versionCode 7 / versionName 0.4.0

Verificado: testes unitários verdes; APKs debug+release OK; em Android 16
(API 36, Pixel 6) upgrade in-place, criar nota -> autosave -> Room -> lista,
editor e chips de modo/formato sem crashes. README atualizado.
2026-11-06 08:48:16 -03:00
lgcosta 641c374a03 fix(build): restaurar targetSdk=35 perdido no bump da v0.2.2
Sem targetSdkVersion no manifest, o AGP usou minSdk (26) como alvo:
o instalador do Android 16 (One UI 8) bloqueia o APK como 'versão
antiga do Android'. Restore targetSdk=35 + versionCode 6 / 0.3.1.

Verificado em emulador Android 16 (API 36, Pixel 6): install sem
bloqueio, boot limpo, fluxo config -> lista -> status/busca/empty state.
2026-11-05 22:09:50 -03:00
lgcosta a8c8a5a998 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
2026-11-05 21:48:16 -03:00
13 changed files with 367 additions and 154 deletions

No files matched your search

+23 -21
View File
@@ -40,7 +40,7 @@ unzip -q commandlinetools-linux-*.zip -d ~/Android/Sdk/cmdline-tools
mv ~/Android/Sdk/cmdline-tools/cmdline-tools ~/Android/Sdk/cmdline-tools/latest
yes | ~/Android/Sdk/cmdline-tools/latest/bin/sdkmanager --licenses
~/Android/Sdk/cmdline-tools/latest/bin/sdkmanager \
"platform-tools" "platforms;android-35" "build-tools;35.0.0"
"platform-tools" "platforms;android-36" "build-tools;36.0.0"
# 3) ~/.bashrc (ou ~/.zshrc)
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk
@@ -48,8 +48,8 @@ export ANDROID_HOME=$HOME/Android/Sdk
export PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools
```
O projeto usa o **Gradle Wrapper** (8.11.1) — não é preciso instalar Gradle.
Se clonar sem os jars do wrapper, gere-os uma vez: `gradle wrapper --gradle-version 8.11.1`
O projeto usa o **Gradle Wrapper** (8.13) — não é preciso instalar Gradle.
Se clonar sem os jars do wrapper, gere-os uma vez: `gradle wrapper --gradle-version 8.13`
(ou abra no Android Studio, que regenera automaticamente).
## Estrutura do repositório
@@ -57,9 +57,9 @@ Se clonar sem os jars do wrapper, gere-os uma vez: `gradle wrapper --gradle-vers
```
notas-online-android/
├── settings.gradle.kts / build.gradle.kts / gradle.properties
├── gradle/wrapper/ # Gradle 8.11.1
├── gradle/wrapper/ # Gradle 8.13
├── app/
│ ├── build.gradle.kts # AGP 8.7.3 · Kotlin 2.0.21 · Compose BOM 2024.12
│ ├── build.gradle.kts # AGP 8.13.2 · Kotlin 2.2.20 · Compose BOM 2026.06
│ ├── proguard-rules.pro
│ └── src/main/
│ ├── AndroidManifest.xml # INTERNET + ACCESS_NETWORK_STATE; cleartext p/ LAN
@@ -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,17 @@ 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).
- **Emulador Android 16 (API 36, x86_64, Pixel 6)** — v0.4.1: build release R8 limpo,
`testDebugUnitTest` verde; push/pull 5xx → `Transient` com retry, `Failed` → Error,
SSE multilinha, sem crashes.
- **Emulador Android 16 (API 36, x86_64, Pixel 6)** — v0.4.0: upgrade in-place sem bloqueio,
criar nota → autosave → persiste no Room → lista atualiza; editor WYSIWYG/Preview ok;
sem crashes (logcat AndroidRuntime limpo).
- **Emulador Android 16 (API 36, x86_64, Pixel 6)** — v0.3.1: instalação sem bloqueio de
"versão antiga", boot limpo e fluxo config → lista (status, busca, empty state) verificados.
- **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);
@@ -165,3 +165,5 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk
- Sync reativo incremental (o pull compara hashes por nota; milhares de notas pediriam um
endpoint de delta no servidor).
- Multi-perfil/servidores e sincronização de anexos.
- Material 3 Expressive completo (`MaterialExpressiveTheme` + componentes 1.5.x) quando a
linha 1.5 sair de beta — hoje o estável (material3 1.4.0) não expõe a API.
+17 -17
View File
@@ -8,13 +8,14 @@ plugins {
android {
namespace = "com.labexposed.notas"
compileSdk = 35
compileSdk = 36
defaultConfig {
applicationId = "com.labexposed.notas"
minSdk = 26
versionCode = 4
versionName = "0.2.2"
targetSdk = 36
versionCode = 8
versionName = "0.4.1"
}
buildTypes {
@@ -41,32 +42,31 @@ android {
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
val composeBom = platform("androidx.compose:compose-bom:2026.06.00")
implementation(composeBom)
// UI
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.activity:activity-compose:1.10.1")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.navigation:navigation-compose:2.8.5")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.navigation:navigation-compose:2.9.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.0")
// Dados locais
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("androidx.room:room-runtime:2.8.3")
implementation("androidx.room:room-ktx:2.8.3")
ksp("androidx.room:room-compiler:2.8.3")
implementation("androidx.datastore:datastore-preferences:1.1.7")
// Rede + serialização
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
// Sync em segundo plano
implementation("androidx.work:work-runtime-ktx:2.9.1")
implementation("androidx.work:work-runtime-ktx:2.10.1")
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
}
@@ -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)
@@ -59,8 +59,11 @@ class SseClient(private val baseUrl: String, private val token: String, private
currentCoroutineContext().ensureActive()
val line = reader.readLine() ?: break
when {
line.startsWith("data: ") -> data.append(line.removePrefix("data: "))
line.startsWith("data:") -> data.append(line.removePrefix("data:").trimStart())
line.startsWith("data:") -> {
// evento multilinha: junta os blocos com \n (spec SSE)
if (data.isNotEmpty()) data.append('\n')
data.append(line.removePrefix("data:").trimStart())
}
line.isEmpty() && data.isNotEmpty() -> {
val raw = data.toString()
data = StringBuilder()
@@ -22,5 +22,7 @@ sealed class SyncOutcome {
data class Success(val pushed: Int, val pulled: Int, val conflicts: Int) : SyncOutcome()
data class NetworkDown(val message: String) : SyncOutcome()
data object Unauthorized : SyncOutcome()
/** Erro transitório do servidor (5xx): pode repetir depois. */
data class Transient(val message: String) : SyncOutcome()
data class Failed(val message: String) : SyncOutcome()
}
@@ -81,6 +81,19 @@ class SyncEngine(
status.set { it.copy(phase = SyncPhase.Offline, message = push.message) }
return push
}
if (push is SyncOutcome.Transient) {
status.set { it.copy(phase = SyncPhase.Offline, message = push.message) }
return push
}
if (push is SyncOutcome.Failed) {
status.set { it.copy(phase = SyncPhase.Error, message = push.message) }
return push
}
if (push is SyncOutcome.NotConfigured) {
status.set { it.copy(phase = SyncPhase.NotConfigured) }
return push
}
val pull = pull(client)
if (pull is SyncOutcome.Unauthorized) {
@@ -91,6 +104,14 @@ class SyncEngine(
status.set { it.copy(phase = SyncPhase.Offline, message = pull.message) }
return pull
}
if (pull is SyncOutcome.Transient) {
status.set { it.copy(phase = SyncPhase.Offline, message = pull.message) }
return pull
}
if (pull is SyncOutcome.Failed) {
status.set { it.copy(phase = SyncPhase.Error, message = pull.message) }
return pull
}
val ok = (push as SyncOutcome.Success) to (pull as SyncOutcome.Success)
val conflicts = ok.first.conflicts + ok.second.conflicts
@@ -151,6 +172,7 @@ class SyncEngine(
}
is ApiResult.Network -> return SyncOutcome.NetworkDown(resp.message)
is ApiResult.Unauthorized -> return SyncOutcome.Unauthorized
is ApiResult.Server -> return serverOutcome("create ${note.id}", resp)
else -> return SyncOutcome.Failed("create ${note.id}: ${describe(resp)}")
}
}
@@ -189,6 +211,7 @@ class SyncEngine(
}
is ApiResult.Network -> return SyncOutcome.NetworkDown(resp.message)
is ApiResult.Unauthorized -> return SyncOutcome.Unauthorized
is ApiResult.Server -> return serverOutcome("update ${note.id}", resp)
else -> return SyncOutcome.Failed("update ${note.id}: ${describe(resp)}")
}
}
@@ -200,6 +223,7 @@ class SyncEngine(
is ApiResult.Ok, is ApiResult.NotFound -> Unit
is ApiResult.Network -> return SyncOutcome.NetworkDown(resp.message)
is ApiResult.Unauthorized -> return SyncOutcome.Unauthorized
is ApiResult.Server -> return serverOutcome("delete ${note.id}", resp)
else -> return SyncOutcome.Failed("delete ${note.id}: ${describe(resp)}")
}
}
@@ -290,12 +314,14 @@ class SyncEngine(
}
is ApiResult.Network -> return SyncOutcome.NetworkDown(cats.message)
is ApiResult.Unauthorized -> return SyncOutcome.Unauthorized
is ApiResult.Server -> return serverOutcome("categories", cats)
else -> {}
}
val list = client.listNotes()
if (list is ApiResult.Network) return SyncOutcome.NetworkDown(list.message)
if (list is ApiResult.Unauthorized) return SyncOutcome.Unauthorized
if (list is ApiResult.Server) return serverOutcome("list", list)
if (list !is ApiResult.Ok) return SyncOutcome.Failed("list: ${describe(list)}")
val serverIds = list.value.map { it.id }.toSet()
@@ -345,6 +371,10 @@ class SyncEngine(
private fun NoteDetailDto.toEntity() = toNoteEntity()
private fun serverOutcome(prefix: String, r: ApiResult.Server): SyncOutcome =
if (r.code >= 500) SyncOutcome.Transient("$prefix: HTTP ${r.code} ${r.message}")
else SyncOutcome.Failed("$prefix: HTTP ${r.code} ${r.message}")
private fun describe(r: ApiResult<*>): String = when (r) {
is ApiResult.Server -> "HTTP ${r.code} ${r.message}"
is ApiResult.Network -> r.message
@@ -30,6 +30,7 @@ class SyncWorker(context: Context, params: WorkerParameters) :
)
return when (val outcome = engine.fullSync(trigger = inputData.getString(TRIGGER) ?: "auto")) {
is com.labexposed.notas.domain.SyncOutcome.NetworkDown -> Result.retry()
is com.labexposed.notas.domain.SyncOutcome.Transient -> Result.retry()
is com.labexposed.notas.domain.SyncOutcome.Failed -> Result.failure()
is com.labexposed.notas.domain.SyncOutcome.Unauthorized -> Result.success()
else -> Result.success()
@@ -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,
)
}
+5 -5
View File
@@ -1,8 +1,8 @@
// Arquivo raiz — só declara plugins; versão centralizada aqui.
plugins {
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
id("com.google.devtools.ksp") version "2.0.21-1.0.28" apply false
id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.2.20" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.2.20" apply false
id("com.google.devtools.ksp") version "2.2.20-2.0.4" apply false
}
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME