8 Commits
Author SHA1 Message Date
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
codeall 8561ae2f8b fix: robustez de sync e cancelamento (v0.2.2)
- SyncEngine: CancellationException volta a propagar (não vira erro falso)
- SyncWorker: Failed (4xx/5xx) agora é failure, só NetworkDown retenta
- ApiClient: métodos suspend com delay() em vez de Thread.sleep
- SseClient: cancelamento fecha o socket; parse tolera 'data:' sem espaço
- Room v3: migração real 2->3 (índice único outbox) sem apagar a fila
- versionCode 4 / versionName 0.2.2
2026-11-05 21:02:10 -03:00
lgcosta 34a6c38540 chore(wrapper): Added wrapper jar and enabled gradlew execution
- Added gradle-wrapper.jar to enable version-pinned Gradle bootstrapping.
- Set executable permission on gradlew for direct wrapper invocation.
2026-11-05 20:48:17 -03:00
lgcosta 57edfbe4df feat: exclusão de notas na UI (lista e editor) com confirmação e sync
- Botão 🗑 no card da lista e na barra do editor; AlertDialog de confirmação
  explicando o efeito (remoto sobe na sync; local-only some direto)
- Exclusões já eram enfileiradas (pendingDelete + op delete) e o engine já
  tratava 404; propagação via SSE 'deleted' para outras sessões
- versionCode 3 / versionName 0.2.1; README atualizado
- Testado no emulador: delete pela lista → servidor 0 notas; delete pelo
  editor → idem, com volta à lista; pull de nota da Web e exclusão seguinte ok
2026-11-05 07:48:57 -03:00
lgcosta 9508dbf24a chore: ignorar .kotlin/ (cache do plugin Kotlin) 2026-11-04 20:57:56 -03:00
lgcosta 400ab066b2 chore: versionCode 2 / versionName 0.2.0 (release v0.2.0) 2026-11-04 20:56:08 -03:00
lgcosta 61d4abebbd feat: v0.2.0 — sincronização do botão garantida, SSE em tempo real, WYSIWYG/Preview e lista rica
Sync (bug do celular real):
- mudanças só de metadados (título/formato/categorias) entram na fila
- auto-reconciliação: toda rodada enfileira notas sujas sem entrada na fila
- re-enfileira update se o usuário digitou durante o push
- sync no ON_RESUME; botão Sincronizar com spinner no lugar do ícone

Tempo real (SSE):
- SseClient (OkHttp, Dispatchers.IO, backoff) consome /api/events com Bearer
- saved: nota limpa recarrega sozinha (editor aberto incluído, com rekey por meta),
  nota suja mostra banner Recarregar/Manter; snackbar na lista
- presence: 👥N na lista/editor quando outra sessão (web ou app) abre a mesma nota
- SessionId único por instalação, enviado em presence e ecoado nos saves

Editor moderno (WebView nativo, sem deps novas):
- modos Editar / WYSIWYG / Preview; WYSIWYG = contenteditable com serializador
  html→md em JS na página-asset + ponte @JavascriptInterface (tudo na main thread)
- toolbar Markdown no modo texto (wrap/prefix seleção) e execCommand no WYSIWYG
- Preview renderiza md via /api/render; links não navegam fora do app
- lista: MediumTopAppBar, chips de filtro (categoria com caminho da árvore e tags),
  cards com status ☁/●/🗑/ e 👥, FAB estendido; telas em Material 3 dynamic color

Testado no emulador Android 15: título-only sync ✅, SSE ao vivo ✅, WYSIWYG→servidor ✅,
👥 presença ✅, conflito 409 com cópia única ✅
2026-11-04 20:52:59 -03:00
20 changed files with 1423 additions and 222 deletions

No files matched your search

+1
View File
@@ -1,5 +1,6 @@
*.iml
.gradle/
.kotlin/
build/
local.properties
.idea/
+39 -19
View File
@@ -90,20 +90,29 @@ notas-online-android/
rebaixadas (GET completo); notas que sumiram do servidor (e não estão na fila) são
removidas localmente.
5. **Gatilhos**: WorkManager periódico (1h, exige rede, backoff exponencial) · rede
restabelecida (`ConnectivityManager` callback) · após cada edição com conteúdo alterado ·
botão **Sincronizar** · troca de configuração.
6. **Autosave do editor**: grava local + enfileira após **10 s de ociosidade** (cada tecla
reinicia a contagem) — igual à política web.
restabelecida (`ConnectivityManager` callback) · **a cada edição com autosave (10 s de
ociosidade), incluindo mudanças só de título/formato/categorias** · **ao voltar para o app**
(`ON_RESUME`) · botão **Sincronizar** · troca de configuração. Uma auto-reconciliação a cada
rodada enfileira qualquer nota suja sem entrada na fila (recupera estados presos).
6. **Tempo real via SSE** (`GET /api/events`, OkHttp, reconexão com backoff, só em primeiro
plano): eventos `saved` atualizam a UI na hora — nota limpa aberta no editor é recarregada
ao vivo; nota suja mostra banner "Mudou no servidor — Recarregar/Manter"; snackbar na lista.
Eventos `presence` desenham **👥N** na nota quando outra sessão (app ou navegador web) está
com ela aberta.
7. **WYSIWYG e Preview** (WebView nativo, sem dependência extra): modo WYSIWYG renderiza o
Markdown (via `/api/render` do servidor) num `contenteditable`; cada edição é convertida de
volta para Markdown por um serializador JS embutido (títulos, negrito/itálico/tachado, listas,
citação, código, links, tabelas) e sincronizada — notas `.html` editam o HTML preservando
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).
## Telas
## UI (Material 3, dynamic color, edge-to-edge) — v0.3.0
- **Servidor**: URL da API + token (`NOTES_TOKEN`) + autor; botão *Testar conexão* (lista
notas via `GET /api/notes`) antes de salvar.
- **Lista**: banner de status colorido — cinza *não configurado*, cinza-escuro *Offline (N na
fila)*, azul *Sincronizando…*, verde *Sincronizado às HH:mm · N pendente(s) · ⚠ conflitos*,
vermelho *erro/401*; itens mostram ● não enviado / 🗑 fila / ⚠ nota de conflito.
- **Editor**: título, chips de formato (TXT/MD/HTML), chips multi-seleção de categorias
(árvore espelhada do servidor), corpo monoespaçado; autosave ocioso + botão Salvar.
- **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
@@ -121,23 +130,34 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk
## Testado em
- **Build**: `assembleRelease` limpo (JDK 21 / AGP 8.7.3 / Kotlin 2.0.21 / SDK 35), APK assinado
com R8 ≈ **1,6 MB** (debug ≈ 17 MB).
- **Build**: `testDebugUnitTest` + `assembleDebug` + `assembleRelease` limpos (JDK 21 / AGP 8.7.3 /
Kotlin 2.0.21 / SDK 35 / Compose BOM 2025.06); release R8 ≈ **1,6 MB**, debug ≈ 17 MB.
- **Emulador Android 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);
- editar offline → fila resiste à queda de rede → push automático ao restabelecer (`ConnectivityManager`);
- nota criada na Web aparece no device via pull;
- **mudança só de título sincroniza** (bug reportado em celular real, corrigido: metadados
entram na fila + auto-reconciliação + sync no `ON_RESUME`);
- **SSE ao vivo**: PUT vindo da Web atualiza o editor aberto em ~2 s, sem toque; snackbar na
lista; **👥 presença** aparece quando outra sessão abre a mesma nota;
- **WYSIWYG**: digitar no `contenteditable` → serialização html→md na ponte JS → autosave →
servidor recebe o texto; Preview renderiza o Markdown via `/api/render`;
- **conflito real**: device e Web editam a mesma nota → HTTP 409 (`base_hash`) → nota principal
adota o servidor e o texto do Android vira exatamente **uma** cópia "… (conflito)"
(idempotente; sincronizações serializadas por mutex + índices únicos na outbox).
- Integração com a API: mesmos contratos do web client (`base_hash`/409, categorias, revisões).
O servidor normaliza `categories`/`tags` vazios como `[]` (null quebrava clientes com
tipos não-nuláveis, ex. kotlinx.serialization).
- Integração com a API: mesmos contratos do web client (`base_hash`/409, categorias, revisões,
`/api/events`, `/api/presence`). O servidor normaliza `categories`/`tags` vazios como `[]`
(null quebrava clientes com tipos não-nuláveis, ex. kotlinx.serialization).
## Roadmap honesto (não implementado aqui)
- SSE (`/api/events`) no Android para push tempo-real em vez de gatilho pós-save (OkHttp SSE).
- Token em Keystore/EncryptedSharedPreferences (hoje: sandbox do app via DataStore).
- Editor WYSIWYG/realce de sintaxe mobile (fora do escopo "mais leve possível").
- 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.
+3 -3
View File
@@ -14,8 +14,8 @@ android {
applicationId = "com.labexposed.notas"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
versionCode = 6
versionName = "0.3.1"
}
buildTypes {
@@ -42,7 +42,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,28 +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 com.labexposed.notas.domain.SyncPhase
import com.labexposed.notas.domain.SyncUiState
import androidx.lifecycle.Lifecycle
import com.labexposed.notas.ui.EditorScreen
import com.labexposed.notas.ui.NotesListScreen
import com.labexposed.notas.ui.NotasViewModel
@@ -34,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_() }
}
@@ -45,6 +34,9 @@ private fun NotasApp_(vm: NotasViewModel = viewModel()) {
val nav = rememberNavController()
val settings by vm.settings.collectAsState()
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { vm.onResumed() }
LifecycleEventEffect(Lifecycle.Event.ON_PAUSE) { vm.onPaused() }
NavHost(navController = nav, startDestination = if (settings.configured) "list" else "settings") {
composable("settings") {
SettingsScreen(vm, onDone = { nav.navigate("list") { popUpTo("settings") { inclusive = true } } })
@@ -63,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)
@@ -4,10 +4,12 @@ import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
@Database(
entities = [NoteEntity::class, OutboxEntity::class, CategoryEntity::class, MetaEntity::class],
version = 2,
version = 3,
exportSchema = false,
)
abstract class NotasDatabase : RoomDatabase() {
@@ -20,6 +22,17 @@ abstract class NotasDatabase : RoomDatabase() {
@Volatile
private var instance: NotasDatabase? = null
/**
* v1→v2→v3: a mudança real foi o índice único (noteId, op) na outbox
* (v2). Migração recria o índice sem tocar nos dados — a fila não
* enviada sobrevive ao upgrade, ao contrário do destructive fallback.
*/
val MIGRATION_2_3: Migration = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_outbox_noteId_op` ON `outbox` (`noteId`, `op`)")
}
}
fun get(context: Context): NotasDatabase =
instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
@@ -27,7 +40,7 @@ abstract class NotasDatabase : RoomDatabase() {
NotasDatabase::class.java,
"notas.db",
)
.fallbackToDestructiveMigration() // v1→v2: dados de teste; em produção usar migração real
.addMigrations(MIGRATION_2_3)
.build().also { instance = it }
}
}
@@ -1,5 +1,6 @@
package com.labexposed.notas.data.remote
import kotlinx.coroutines.delay
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
@@ -8,7 +9,6 @@ import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.util.concurrent.TimeUnit
/**
* Cliente HTTP da API notas-online: Bearer token, timeout curto, retry com
* backoff apenas para falhas de rede (IOException), mapeamento de status em
@@ -29,7 +29,7 @@ class ApiClient(private val baseUrl: String, private val token: String) {
private fun url(path: String) = baseUrl.trimEnd('/') + path
/** Executa com até 2 tentativas; erros de rede/URL inválida viram Network. */
private inline fun <reified T> call(
private suspend inline fun <reified T> call(
method: String,
path: String,
body: String? = null,
@@ -65,7 +65,7 @@ class ApiClient(private val baseUrl: String, private val token: String) {
}
} catch (e: IOException) {
lastError = e
if (attempt == 0) Thread.sleep(500)
if (attempt == 0) delay(500)
} catch (e: IllegalArgumentException) {
// URL malformada (configuração errada): não adianta repetir
return ApiResult.Network("URL inválida: ${e.message}")
@@ -74,19 +74,25 @@ class ApiClient(private val baseUrl: String, private val token: String) {
return ApiResult.Network(lastError?.message ?: "sem conexão")
}
fun testConnection(): ApiResult<List<NoteSummaryDto>> = call("GET", "/api/notes")
suspend fun testConnection(): ApiResult<List<NoteSummaryDto>> = call("GET", "/api/notes")
fun listNotes(): ApiResult<List<NoteSummaryDto>> = call("GET", "/api/notes")
suspend fun listNotes(): ApiResult<List<NoteSummaryDto>> = call("GET", "/api/notes")
fun listCategories(): ApiResult<List<CategoryDto>> = call("GET", "/api/categories")
suspend fun listCategories(): ApiResult<List<CategoryDto>> = call("GET", "/api/categories")
fun getNote(id: String): ApiResult<NoteDetailDto> = call("GET", "/api/notes/$id")
suspend fun getNote(id: String): ApiResult<NoteDetailDto> = call("GET", "/api/notes/$id")
fun createNote(dto: NoteWriteDto): ApiResult<NoteDetailDto> =
suspend fun createNote(dto: NoteWriteDto): ApiResult<NoteDetailDto> =
call("POST", "/api/notes", json.encodeToString(dto))
fun updateNote(id: String, dto: NoteWriteDto): ApiResult<NoteDetailDto> =
suspend fun updateNote(id: String, dto: NoteWriteDto): ApiResult<NoteDetailDto> =
call("PUT", "/api/notes/$id", json.encodeToString(dto))
fun deleteNote(id: String): ApiResult<Unit> = call("DELETE", "/api/notes/$id")
suspend fun deleteNote(id: String): ApiResult<Unit> = call("DELETE", "/api/notes/$id")
suspend fun renderMd(content: String): ApiResult<String> {
val body = """{"content":${json.encodeToString(content)},"format":"md"}"""
val r = call<RenderDto>("POST", "/api/render", body)
return if (r is ApiResult.Ok) ApiResult.Ok((r.value as RenderDto).html) else r as ApiResult<Nothing>
}
}
@@ -1,5 +1,7 @@
package com.labexposed.notas.data.remote
import com.labexposed.notas.data.local.NoteEntity
import java.time.Instant
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -60,6 +62,9 @@ data class ConflictDto(
@SerialName("updated_at") val updatedAt: String = "",
)
@Serializable
data class RenderDto(val html: String = "")
/** Resultado tipado das chamadas — sem exceções de fluxo de controle. */
sealed class ApiResult<out T> {
data class Ok<T>(val value: T) : ApiResult<T>()
@@ -69,3 +74,18 @@ sealed class ApiResult<out T> {
data class Network(val message: String) : ApiResult<Nothing>()
data class Server(val code: Int, val message: String) : ApiResult<Nothing>()
}
// ---- mapeamento para a entidade local ----
fun NoteDetailDto.toNoteEntity(): NoteEntity = NoteEntity(
id = id,
title = title,
format = format,
categories = (categories ?: emptyList()).joinToString(","),
tags = (tags ?: emptyList()).joinToString(","),
content = content,
serverHash = hash,
createdAt = runCatching { Instant.parse(createdAt).toEpochMilli() }.getOrElse { System.currentTimeMillis() },
updatedAt = runCatching { Instant.parse(updatedAt).toEpochMilli() }.getOrElse { System.currentTimeMillis() },
dirty = false,
)
@@ -0,0 +1,75 @@
package com.labexposed.notas.data.remote
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.isActive
import kotlinx.coroutines.job
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit
/**
* Cliente SSE (Server-Sent Events) para eventos em tempo real do servidor:
* `saved` (nota mudou em outra sessão) e `presence` (quem está com o quê aberto).
* Reconexão com backoff; roda apenas com o app em primeiro plano — em
* background o WorkManager assume.
*/
class SseClient(private val baseUrl: String, private val token: String, private val sessionId: String) {
private val http = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS) // stream longo
.build()
fun connect(scope: CoroutineScope, onEvent: (SseEvent) -> Unit): Job = scope.launch(Dispatchers.IO) {
var backoff = 1_000L
while (isActive) {
try {
streamOnce(onEvent)
backoff = 1_000L // stream terminou limpo (scope cancelado não chega aqui)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
// rede caiu / servidor fora: espera e tenta de novo
}
delay(backoff)
backoff = (backoff * 2).coerceAtMost(30_000L)
}
}
private suspend fun streamOnce(onEvent: (SseEvent) -> Unit) {
val request = Request.Builder()
.url(baseUrl.trimEnd('/') + "/api/events?session=" + sessionId)
.header("Authorization", "Bearer $token")
.header("Accept", "text/event-stream")
.build()
val call = http.newCall(request)
currentCoroutineContext().job.invokeOnCompletion { runCatching { call.cancel() } }
call.execute().use { resp ->
if (!resp.isSuccessful) throw java.io.IOException("SSE HTTP ${resp.code}")
val reader = resp.body?.source()?.inputStream()?.bufferedReader() ?: throw java.io.IOException("sem corpo")
var data = StringBuilder()
while (true) {
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.isEmpty() && data.isNotEmpty() -> {
val raw = data.toString()
data = StringBuilder()
runCatching { SseEvent.parse(raw) }.getOrNull()?.let(onEvent)
}
else -> { /* comentários ": ping" e campos outros */ }
}
}
}
throw java.io.IOException("SSE encerrado")
}
}
@@ -0,0 +1,38 @@
package com.labexposed.notas.data.remote
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class SsePresenceSession(val session: String, val notes: List<String>? = null)
@Serializable
data class SseEventDto(
val type: String = "",
@SerialName("note_id") val noteId: String = "",
val hash: String = "",
val title: String = "",
val author: String = "",
val session: String = "",
val deleted: Boolean = false,
val sessions: List<SsePresenceSession>? = null,
)
private val json = Json { ignoreUnknownKeys = true }
sealed class SseEvent {
data class Saved(val noteId: String, val hash: String, val title: String, val session: String, val deleted: Boolean) : SseEvent()
data class Presence(val sessions: List<SsePresenceSession>) : SseEvent()
companion object {
fun parse(raw: String): SseEvent? {
val dto = json.decodeFromString<SseEventDto>(raw)
return when (dto.type) {
"saved" -> Saved(dto.noteId, dto.hash, dto.title, dto.session, dto.deleted)
"presence", "hello" -> Presence(dto.sessions ?: emptyList())
else -> null
}
}
}
}
@@ -0,0 +1,16 @@
package com.labexposed.notas.domain
import android.content.Context
/** Id de sessão estável por instalação — usado em presence/SSE e no eco de `session`. */
object SessionId {
fun get(context: Context): String {
val p = context.getSharedPreferences("notas", Context.MODE_PRIVATE)
var s = p.getString("sid", null)
if (s.isNullOrEmpty()) {
s = java.util.UUID.randomUUID().toString()
p.edit().putString("sid", s).apply()
}
return s
}
}
@@ -9,7 +9,7 @@ import com.labexposed.notas.data.remote.ApiClient
import com.labexposed.notas.data.remote.ApiResult
import com.labexposed.notas.data.remote.NoteDetailDto
import com.labexposed.notas.data.remote.NoteWriteDto
import java.time.Instant
import com.labexposed.notas.data.remote.toNoteEntity
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.withLock
@@ -37,6 +37,7 @@ class SyncEngine(
private val db: NotasDatabase,
private val settings: SettingsRepository,
private val status: SyncStatusStore,
private val session: String = "android",
) {
private suspend fun api(): ApiClient? {
@@ -50,6 +51,8 @@ class SyncEngine(
SyncStatusStore.syncMutex.withLock {
try {
runSync(trigger)
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
android.util.Log.e("SyncEngine", "fullSync($trigger) falhou", e)
status.set { it.copy(phase = SyncPhase.Error, message = e.message ?: "erro interno") }
@@ -106,6 +109,21 @@ class SyncEngine(
// ------------------------------------------------------------------ push
private suspend fun push(client: ApiClient, author: String): SyncOutcome {
// auto-reconciliação: notas sujas sem entrada na fila (ex.: só mudaram
// metadados, ou ficaram presas de versões anteriores do app)
for (n in db.notes().all()) {
if (n.dirty && !n.pendingDelete &&
!db.outbox().has(n.id, "create") && !db.outbox().has(n.id, "update")
) {
db.outbox().enqueue(
OutboxEntity(
noteId = n.id,
op = if (n.isLocalOnly) "create" else "update",
queuedAt = System.currentTimeMillis(),
),
)
}
}
val queue = db.outbox().queue()
if (queue.isEmpty()) return SyncOutcome.Success(0, 0, 0)
var pushed = 0
@@ -152,6 +170,12 @@ class SyncEngine(
),
)
db.outbox().drop(entry.seq)
if (!clean && !fresh.pendingDelete) {
// conteúdo mudou durante o push: mantém na fila
db.outbox().enqueue(
OutboxEntity(noteId = fresh.id, op = "update", queuedAt = System.currentTimeMillis()),
)
}
pushed++
}
is ApiResult.Conflict -> conflicts += resolveConflict(client, note, resp.serverHash)
@@ -316,23 +340,10 @@ class SyncEngine(
author = author,
message = "sync android",
baseHash = serverHash,
session = this@SyncEngine.session,
)
private fun NoteDetailDto.toEntity() = NoteEntity(
id = id,
title = title,
format = format,
categories = (categories ?: emptyList()).joinToString(","),
tags = (tags ?: emptyList()).joinToString(","),
content = content,
serverHash = hash,
createdAt = parseMillis(createdAt),
updatedAt = parseMillis(updatedAt),
dirty = false,
)
private fun parseMillis(iso: String): Long =
runCatching { Instant.parse(iso).toEpochMilli() }.getOrElse { System.currentTimeMillis() }
private fun NoteDetailDto.toEntity() = toNoteEntity()
private fun describe(r: ApiResult<*>): String = when (r) {
is ApiResult.Server -> "HTTP ${r.code} ${r.message}"
@@ -22,10 +22,15 @@ class SyncWorker(context: Context, params: WorkerParameters) :
override suspend fun doWork(): Result {
val db = NotasDatabase.get(applicationContext)
val engine = SyncEngine(db, SettingsRepository(applicationContext), SyncStatusStore)
val engine = SyncEngine(
db,
SettingsRepository(applicationContext),
SyncStatusStore,
com.labexposed.notas.domain.SessionId.get(applicationContext),
)
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.Failed -> Result.retry()
is com.labexposed.notas.domain.SyncOutcome.Failed -> Result.failure()
is com.labexposed.notas.domain.SyncOutcome.Unauthorized -> Result.success()
else -> Result.success()
}
@@ -13,8 +13,10 @@ import com.labexposed.notas.domain.SettingsRepository
import com.labexposed.notas.domain.SyncStatusStore
import com.labexposed.notas.domain.SyncUiState
import com.labexposed.notas.sync.SyncScheduler
import android.content.Context
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -22,10 +24,14 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import com.labexposed.notas.data.remote.SseClient
import com.labexposed.notas.data.remote.SseEvent
import com.labexposed.notas.data.remote.toNoteEntity
sealed class ListEvent {
data class ConnectionOk(val notes: Int) : ListEvent()
data class ConnectionFail(val message: String) : ListEvent()
data class RemoteChange(val title: String) : ListEvent()
}
class NotasViewModel(app: Application) : AndroidViewModel(app) {
@@ -54,8 +60,104 @@ class NotasViewModel(app: Application) : AndroidViewModel(app) {
private val _events = MutableStateFlow<ListEvent?>(null)
val events: StateFlow<ListEvent?> = _events
/** id de sessão estável por instalação (mesmo valor usado no presence/SSE). */
val sid: String = com.labexposed.notas.domain.SessionId.get(getApplication<android.app.Application>())
private var sseJob: Job? = null
private var sseApi: ApiClient? = null
/** noteId → nº de outras sessões com a nota aberta (presença). */
val presence = MutableStateFlow<Map<String, Int>>(emptyMap())
/** notas sujas que mudaram remotamente (banner no editor). */
val remoteChanged = MutableStateFlow<Set<String>>(emptySet())
/** id da nota atualmente aberta no editor (para refresh ao vivo via SSE). */
@Volatile
var openNoteId: String? = null
/** conteúdo novo para o editor aberto (consumido e limpo pela UI). */
val editorRefresh = MutableStateFlow<Pair<String, String>?>(null)
fun onResumed() {
viewModelScope.launch {
val s = settingsRepo.current()
if (!s.configured) return@launch
if (sseJob?.isActive != true) {
val client = SseClient(s.serverUrl, s.token, sid)
sseApi = ApiClient(s.serverUrl, s.token)
sseJob = client.connect(viewModelScope) { ev -> handleSse(ev) }
}
SyncScheduler.runNow(getApplication(), "resume")
}
}
fun onPaused() {
sseJob?.cancel()
sseJob = null
}
private fun handleSse(ev: SseEvent) = viewModelScope.launch(Dispatchers.IO) {
when (ev) {
is SseEvent.Presence -> {
val map = mutableMapOf<String, Int>()
for (s in ev.sessions) {
if (s.session == sid) continue
for (n in s.notes ?: emptyList()) map[n] = (map[n] ?: 0) + 1
}
presence.value = map
}
is SseEvent.Saved -> {
if (ev.session == sid) return@launch
val note = db.notes().byId(ev.noteId) ?: return@launch
if (ev.deleted) {
if (!note.dirty) {
db.notes().delete(ev.noteId)
db.outbox().dropFor(ev.noteId)
_events.value = ListEvent.RemoteChange(ev.title)
}
return@launch
}
if (note.serverHash == ev.hash) return@launch
if (!note.dirty) {
val detail = sseApi?.getNote(ev.noteId)
if (detail is ApiResult.Ok) {
db.notes().upsert(detail.value.toNoteEntity())
_events.value = ListEvent.RemoteChange(ev.title)
if (openNoteId == ev.noteId || db.meta().get("rekey:$openNoteId") == ev.noteId) {
openNoteId?.let { editorRefresh.value = it to detail.value.content }
}
}
} else {
remoteChanged.value = remoteChanged.value + ev.noteId
}
}
}
}
/** Nota abriu e mudou remotamente: recarrega do servidor sob demanda. */
fun reloadFromServer(id: String, then: ((NoteEntity?) -> Unit)? = null) = viewModelScope.launch(Dispatchers.IO) {
val detail = sseApi?.getNote(id)
if (detail is ApiResult.Ok) {
db.notes().upsert(detail.value.toNoteEntity())
remoteChanged.value = remoteChanged.value - id
}
then?.invoke(db.notes().byId(id))
}
fun dismissRemoteChange(id: String) {
remoteChanged.value = remoteChanged.value - id
}
fun renderMarkdown(content: String, onResult: (String) -> Unit) = viewModelScope.launch(Dispatchers.IO) {
val html = sseApi?.renderMd(content)
if (html is ApiResult.Ok) onResult(html.value) else onResult("<p>…sem conexão para renderizar…</p>")
}
fun saveSettings(s: Settings) = viewModelScope.launch {
settingsRepo.save(s)
sseApi = if (s.configured) ApiClient(s.serverUrl, s.token) else null
onPaused(); onResumed()
SyncScheduler.runNow(getApplication(), "config")
}
@@ -100,7 +202,7 @@ class NotasViewModel(app: Application) : AndroidViewModel(app) {
return if (db.notes().byId(mapped) != null) mapped else null
}
/** Salva edição local + enfileira mutação + agenda sync (debounce no editor). */
/** Salva edição local (conteúdo OU metadados) + enfileira + agenda sync. */
suspend fun saveNoteSync(
id: String,
title: String,
@@ -112,6 +214,8 @@ class NotasViewModel(app: Application) : AndroidViewModel(app) {
val existing = db.notes().byId(targetId) ?: return
val now = System.currentTimeMillis()
val contentChanged = existing.content != content
val metaChanged = existing.title != title || existing.format != format ||
existing.categories != categories.joinToString(",")
db.notes().upsert(
existing.copy(
id = targetId,
@@ -124,7 +228,7 @@ class NotasViewModel(app: Application) : AndroidViewModel(app) {
conflictNote = "",
),
)
if (contentChanged) {
if (contentChanged || metaChanged) {
// nota em create pendente já está na fila com o conteúdo atual (upsert acima)
if (!db.outbox().has(targetId, "create") && !db.outbox().has(targetId, "update")) {
db.outbox().enqueue(OutboxEntity(noteId = targetId, op = "update", queuedAt = now))
@@ -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) })
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,247 @@
package com.labexposed.notas.ui
import android.annotation.SuppressLint
import android.os.Looper
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import org.json.JSONObject
/**
* Editor WYSIWYG / Preview em WebView nativo (sem dependências extras).
* A página carrega um contenteditable + serializador html→markdown (porta do
* app web) + sanitizador; a ponte AndroidBridge devolve markdown ao Kotlin.
*/
class WysiwygController {
internal var web: WebView? = null
private var ready = false
private var pendingHtml: String? = null
private var pendingMd: ((String) -> Unit)? = null
var onChanged: (() -> Unit)? = null
private val main = android.os.Handler(android.os.Looper.getMainLooper())
/** WebView só aceita chamadas na main thread — sempre postar. */
private fun onMain(block: (WebView) -> Unit) {
val w = web ?: return
if (Looper.myLooper() == Looper.getMainLooper()) block(w) else main.post { if (web === w) block(w) }
}
fun setHtml(html: String) {
if (ready && web != null) {
onMain { it.evaluateJavascript("setHtml(${JSONObject.quote(html)})", null) }
} else {
pendingHtml = html
}
}
fun exec(cmd: String, arg: String? = null) {
val js = if (arg != null) "execCmd('${cmd}', ${JSONObject.quote(arg)})" else "execCmd('${cmd}')"
onMain { it.evaluateJavascript(js, null) }
}
fun requestMd(cb: (String) -> Unit) {
pendingMd = cb
onMain { it.evaluateJavascript("AndroidBridge.deliver(getMd())", null) }
}
inner class Bridge {
@JavascriptInterface
fun onReady() {
ready = true
main.post {
pendingHtml?.let { h ->
web?.evaluateJavascript("setHtml(${JSONObject.quote(h)})", null)
pendingHtml = null
}
}
}
@JavascriptInterface
fun onChanged() {
main.post { onChanged?.invoke() }
}
@JavascriptInterface
fun deliver(md: String) {
main.post {
val cb = pendingMd
pendingMd = null
cb?.invoke(md)
}
}
}
internal fun reset() {
ready = false
pendingHtml = null
pendingMd = null
}
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun EditorWebView(
controller: WysiwygController,
dark: Boolean,
modifier: Modifier = Modifier,
) {
val bridge = remember(controller) { controller.Bridge() }
AndroidView(
modifier = modifier,
factory = { ctx ->
WebView(ctx).apply {
controller.web = this
controller.reset()
settings.javaScriptEnabled = true
settings.domStorageEnabled = false
settings.allowFileAccess = false
settings.allowContentAccess = false
settings.builtInZoomControls = false
setBackgroundColor(0)
addJavascriptInterface(bridge, "AndroidBridge")
webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView, url: String?) {
view.evaluateJavascript("applyTheme(" + dark + ")", null)
}
override fun doUpdateVisitedHistory(view: WebView, url: String?, reload: Boolean) {
// links clicados no preview não navegam fora do app
if (url?.startsWith("http") == true) view.loadUrl("about:blank")
}
}
loadDataWithBaseURL("file:///android_asset/", WYSIWYG_PAGE, "text/html", "UTF-8", null)
}
},
onRelease = {
controller.web = null
it.destroy()
},
)
}
// Página do editor: contenteditable + html2md + sanitização + comandos.
// Sem template literals/backticks para conviver com raw string do Kotlin.
private val WYSIWYG_PAGE = """<!DOCTYPE html>
<html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<style>
:root{--fg:#222;--bg:#fff;--mut:#888;--acc:#1c71d8;--line:#e2e2e2;--soft:#f3f3f3}
.dark{--fg:#e6e6e6;--bg:#1e1e1e;--mut:#9a9a9a;--acc:#78aeff;--line:#3a3a3a;--soft:#2a2a2a}
html,body{margin:0;padding:0;background:var(--bg);color:var(--fg)}
#ed{padding:16px 18px;font:15px/1.6 -apple-system,Roboto,sans-serif;outline:none;min-height:70vh;word-break:break-word}
#ed[contenteditable="true"]:focus{outline:none}
#ed h1{font-size:1.6em;border-bottom:1px solid var(--line);padding-bottom:.2em}
#ed h2{font-size:1.3em}#ed h3{font-size:1.15em}
#ed blockquote{margin:.6em 0;padding:.2em .9em;border-left:3px solid var(--acc);color:var(--mut);background:var(--soft)}
#ed pre{background:var(--soft);border:1px solid var(--line);border-radius:6px;padding:10px;overflow-x:auto}
#ed code{background:var(--soft);padding:1px 5px;border-radius:4px;font-family:monospace}
#ed pre code{background:none;padding:0}
#ed a{color:var(--acc)}
#ed table{border-collapse:collapse}#ed th,#ed td{border:1px solid var(--line);padding:4px 8px}
#ed img{max-width:100%}
#ed ul,#ed ol{padding-left:1.6em}
</style></head><body>
<div id="ed" contenteditable="true" spellcheck="false"></div>
<script>
var FORMAT='md'; var EDITABLE=true; var DEB=0;
function applyTheme(d){document.documentElement.className=d?'dark':'';}
function sanitize(html){
var d=document.createElement('div'); d.innerHTML=html;
var bad=d.querySelectorAll('script,iframe,object,embed');
for(var i=0;i<bad.length;i++) bad[i].remove();
var all=d.querySelectorAll('*');
for(var j=0;j<all.length;j++){
var attrs=all[j].attributes;
for(var k=attrs.length-1;k>=0;k--){ if(/^on/i.test(attrs[k].name)) all[j].removeAttribute(attrs[k].name); }
}
return d.innerHTML;
}
function setHtml(h){ EDITABLE=document.getElementById('ed').getAttribute('contenteditable')==='true';
document.getElementById('ed').innerHTML=sanitize(h||''); }
function setEditable(v){ EDITABLE=v; document.getElementById('ed').setAttribute('contenteditable',v?'true':'false'); }
function setFormat(f){ FORMAT=f; }
function execCmd(c,a){ document.getElementById('ed').focus();
if(c==='formatBlock'&&a){document.execCommand('formatBlock',false,'<'+a+'>');}
else document.execCommand(c,false,a||null); }
function esc(s){return s.replace(/[\\`~_*\[\]]/g,'\\$&').replace(/^#(?=\s|$)/gm,'\\#');}
function inline(node){
if(node.nodeType===3) return esc(node.nodeValue);
var out='';
for(var i=0;i<node.childNodes.length;i++){
var c=node.childNodes[i];
if(c.nodeType===3){ out+=esc(c.nodeValue); continue; }
if(c.nodeType!==1) continue;
var t=c.tagName, inner=inline(c);
if(t==='BR'){out+='\n';}
else if(t==='STRONG'||t==='B'){out+='**'+inner+'**';}
else if(t==='EM'||t==='I'){out+='*'+inner+'*';}
else if(t==='DEL'||t==='S'||t==='STRIKE'){out+='~~'+inner+'~~';}
else if(t==='CODE'){out+='`'+inner+'`';}
else if(t==='A'){out+='['+inner+']('+(c.getAttribute('href')||'')+')';}
else if(t==='IMG'){out+='!['+(c.getAttribute('alt')||'')+']('+(c.getAttribute('src')||'')+')';}
else if(t==='INPUT'){/* tratado no LI */}
else{out+=inner;}
}
return out;
}
function tableMd(el){
var rows=el.querySelectorAll('tr'); if(!rows.length) return '';
var cells=function(tr){var a=[];for(var i=0;i<tr.children.length;i++)a.push(inline(tr.children[i]).trim().replace(/\|/g,'\\|'));return a;};
var head=cells(rows[0]); var body=[];
for(var r=1;r<rows.length;r++) body.push(cells(rows[r]));
var line=function(cs){return '| '+cs.join(' | ')+' |';};
var sep=head.map(function(){return '---';});
var out=[line(head),line(sep)];
for(var b=0;b<body.length;b++) out.push(line(body[b]));
return out.join('\n');
}
function block(root){
var parts=[];
for(var i=0;i<root.childNodes.length;i++){
var el=root.childNodes[i];
if(el.nodeType===3){var tx=esc(el.nodeValue).trim(); if(tx)parts.push(tx); continue;}
if(el.nodeType!==1) continue;
var t=el.tagName, chunk=null;
if(/^H[1-6]$/.test(t)) chunk='#'.repeat(+t[1])+' '+inline(el).trim();
else if(t==='P'){ var nested=el.querySelector('ul,ol,blockquote,pre,table');
chunk=(nested?block(el):inline(el)).trim(); }
else if(t==='UL'||t==='OL'){
var items=[]; var lis=el.children;
for(var li=0;li<lis.length;li++){
var cb=lis[li].querySelector('input[type=checkbox]');
var marker=(t==='UL')?'- ':(li+1)+'. ';
var text=inline(lis[li]).trim();
items.push(cb?marker+(cb.checked?'[x] ':'[ ] ')+text:marker+text);
}
chunk=items.join('\n');
}
else if(t==='BLOCKQUOTE'){ var inner=el.children.length?block(el):inline(el).trim();
chunk=inner.split('\n').map(function(l){return '> '+l;}).join('\n'); }
else if(t==='PRE') chunk='```\n'+el.textContent.replace(/\n+$/,'')+'\n```';
else if(t==='HR') chunk='---';
else if(t==='TABLE') chunk=tableMd(el);
else if(t==='BR') chunk=null;
else { var s=inline(el).trim(); chunk=s; }
if(chunk!==null&&chunk!=='') parts.push(chunk);
}
return parts.join('\n\n');
}
function getMd(){
var ed=document.getElementById('ed');
if(FORMAT==='html') return ed.innerHTML;
return block(ed);
}
document.getElementById('ed').addEventListener('input',function(){
clearTimeout(DEB);
DEB=setTimeout(function(){ if(EDITABLE) AndroidBridge.onChanged(); },350);
});
AndroidBridge.onReady();
</script></body></html>
"""
@@ -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,
)
}
Binary file not shown.
Vendored Regular → Executable
View File
File mode changed.