Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8c8a5a998 | ||
|
|
8561ae2f8b | ||
|
|
34a6c38540 | ||
|
|
57edfbe4df | ||
|
|
9508dbf24a | ||
|
|
400ab066b2 | ||
|
|
61d4abebbd |
No files matched your search
@@ -1,5 +1,6 @@
|
||||
*.iml
|
||||
.gradle/
|
||||
.kotlin/
|
||||
build/
|
||||
local.properties
|
||||
.idea/
|
||||
|
||||
@@ -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,32 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||
|
||||
## Testado em
|
||||
|
||||
- **Build**: `assembleRelease` limpo (JDK 21 / AGP 8.7.3 / Kotlin 2.0.21 / SDK 35), APK assinado
|
||||
com R8 ≈ **1,6 MB** (debug ≈ 17 MB).
|
||||
- **Build**: `testDebugUnitTest` + `assembleDebug` + `assembleRelease` limpos (JDK 21 / AGP 8.7.3 /
|
||||
Kotlin 2.0.21 / SDK 35 / Compose BOM 2025.06); release R8 ≈ **1,6 MB**, debug ≈ 17 MB.
|
||||
- **Emulador Android 15 (x86_64, Pixel 6)** — v0.3.0: edge-to-edge, status de sync compacto,
|
||||
campo de busca, estado vazio com CTA e editor com chips de modo/formato verificados por
|
||||
screenshot + uiautomator (config → lista → criar nota → editor).
|
||||
- **Emulador Android 15 (x86_64, Pixel 6)** contra o servidor notas-online real, cenários:
|
||||
- configuração de servidor com *Testar conexão* → OK;
|
||||
- criar nota offline → autosave por ociosidade (10 s) → `POST` único (sem duplicatas);
|
||||
- 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.
|
||||
@@ -13,9 +13,8 @@ android {
|
||||
defaultConfig {
|
||||
applicationId = "com.labexposed.notas"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "1.0.0"
|
||||
versionCode = 5
|
||||
versionName = "0.3.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -42,7 +41,7 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
|
||||
val composeBom = platform("androidx.compose:compose-bom:2025.06.00")
|
||||
implementation(composeBom)
|
||||
|
||||
// UI
|
||||
|
||||
@@ -3,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+='||'')+')';}
|
||||
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,
|
||||
)
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
Reference in new issue
Block a user