1 Commits
Author SHA1 Message Date
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
12 changed files with 1096 additions and 154 deletions

No files matched your search

+37 -17
View File
@@ -90,20 +90,34 @@ 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)
- **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`, banner de status colorido (Offline/Sincronizando/Sincronizado±
pendências/Erro), chips de filtro (Todas / 📁 categoria com caminho da árvore / #tag), cards
com título+extensão, tempo relativo, tags, chip de categoria(s) e ícone de status por nota
(☁✓ sincronizada, ● âmbar não enviada, 🗑 exclusão na fila, ⚠ conflito/mudança remota, 👥N
presença), FAB estendido "Nova".
- **Editor**: campo de título na top bar, seletor de modo (Editar / WYSIWYG / Preview), chips de
formato (TXT/MD/HTML) e de categorias (multi-seleção), corpo monoespaçado com toolbar de
Markdown (B I S H1 H2 • ❝ 🔗 ` { }) que envolve/prefixa a seleção, selo de presença 👥, e
horário do último salvamento.
- **Servidor**: URL + token + autor com *Testar conexão* e validação de esquema http(s).
## Compilar e instalar
@@ -128,16 +142,22 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk
- 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.
@@ -20,9 +20,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.lifecycle.Lifecycle
import com.labexposed.notas.domain.SyncPhase
import com.labexposed.notas.domain.SyncUiState
import com.labexposed.notas.ui.EditorScreen
@@ -45,6 +47,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 } } })
@@ -89,4 +89,10 @@ class ApiClient(private val baseUrl: String, private val token: String) {
call("PUT", "/api/notes/$id", json.encodeToString(dto))
fun deleteNote(id: String): ApiResult<Unit> = call("DELETE", "/api/notes/$id")
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,65 @@
package com.labexposed.notas.data.remote
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
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 (_: Exception) {
// rede caiu / servidor fora: espera e tenta de novo
}
delay(backoff)
backoff = (backoff * 2).coerceAtMost(30_000L)
}
}
private 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()
http.newCall(request).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) {
val line = reader.readLine() ?: break
when {
line.startsWith("data: ") -> data.append(line.removePrefix("data: "))
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? {
@@ -106,6 +107,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 +168,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 +338,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,7 +22,12 @@ 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()
@@ -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))
@@ -2,27 +2,60 @@ package com.labexposed.notas.ui
import com.labexposed.notas.SyncBanner
import com.labexposed.notas.domain.Settings
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.Button
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CloudDone
import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material.icons.filled.CloudSync
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedAssistChip
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -31,83 +64,123 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.filled.Add
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.isSystemInDarkTheme
import com.labexposed.notas.data.local.NoteEntity
import com.labexposed.notas.domain.SyncPhase
import com.labexposed.notas.domain.SyncUiState
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
private val AUTOSAVE_IDLE_MS = 10_000L
/** Tela de configuração: URL do servidor + token + autor, com teste de conexão. */
private fun ago(ms: Long): String {
val d = (System.currentTimeMillis() - ms) / 1000
return when {
d < 45 -> "agora"
d < 3600 -> "há ${d / 60} min"
d < 86400 -> "há ${d / 3600} h"
else -> java.text.DateFormat.getDateInstance(java.text.DateFormat.SHORT).format(java.util.Date(ms))
}
}
private fun extOf(format: String) = when (format) { "md" -> ".md"; "html" -> ".html"; else -> ".txt" }
/* ================================================================ Servidor */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(vm: NotasViewModel, onDone: () -> Unit) {
val settings by vm.settings.collectAsState()
var url by remember { mutableStateOf(settings.serverUrl) }
var token by remember { mutableStateOf(settings.token) }
var author by remember { mutableStateOf(settings.author) }
var url by remember { mutableStateOf(TextFieldValue(settings.serverUrl)) }
var token by remember { mutableStateOf(TextFieldValue(settings.token)) }
var author by remember { mutableStateOf(TextFieldValue(settings.author)) }
var feedback by remember { mutableStateOf("") }
val scope = rememberCoroutineScope()
val events by vm.events.collectAsState()
LaunchedEffect(events) {
when (val e = events) {
is ListEvent.ConnectionOk -> feedback = "OK! ${e.notes} nota(s) visíveis no servidor."
is ListEvent.ConnectionFail -> feedback = "Falhou: ${e.message}"
is ListEvent.ConnectionOk -> feedback = "✓ Conectado — ${e.notes} nota(s) no servidor."
is ListEvent.ConnectionFail -> feedback = "✗ ${e.message}"
is ListEvent.RemoteChange -> {}
null -> {}
}
if (events != null) vm.consumeEvent()
}
Scaffold(topBar = { TopAppBar(title = { Text("Servidor") }) }) { pad ->
Scaffold(topBar = { MediumTopAppBar(title = { Text("Servidor") }) }) { pad ->
Column(
modifier = Modifier.padding(pad).padding(16.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(pad).padding(horizontal = 20.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
"Conecte o app à sua instância do notas-online. Tudo funciona offline; " +
"as alterações sincronizam quando houver rede.",
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = url, onValueChange = { url = it },
label = { Text("URL da API (ex.: http://192.168.0.10:8080)") },
singleLine = true, modifier = Modifier.fillMaxWidth(),
label = { Text("URL da API") },
placeholder = { Text("http://192.168.0.10:8080") },
singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(14.dp),
)
OutlinedTextField(
value = token, onValueChange = { token = it },
label = { Text("Token de acesso (NOTES_TOKEN)") },
singleLine = true, modifier = Modifier.fillMaxWidth(),
singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(14.dp),
)
OutlinedTextField(
value = author, onValueChange = { author = it },
label = { Text("Autor das revisões") },
singleLine = true, modifier = Modifier.fillMaxWidth(),
singleLine = true, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(14.dp),
)
if (feedback.isNotEmpty()) {
Card(modifier = Modifier.fillMaxWidth()) {
Text(feedback, modifier = Modifier.padding(12.dp), style = MaterialTheme.typography.bodyMedium)
Surface(
color = if (feedback.startsWith("✓")) MaterialTheme.colorScheme.secondaryContainer
else MaterialTheme.colorScheme.errorContainer,
shape = RoundedCornerShape(12.dp),
) {
Text(feedback, Modifier.padding(12.dp), style = MaterialTheme.typography.bodyMedium)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { scope.launch { vm.testConnection(Settings(url, token, author)) } }) {
Text("Testar conexão")
}
TextButton(
enabled = url.isNotBlank() && token.isNotBlank(),
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
TextButton(onClick = {
scope.launch { vm.testConnection(Settings(url.text, token.text, author.text)) }
}) { Text("Testar conexão") }
Spacer(Modifier.weight(1f))
androidx.compose.material3.Button(
enabled = url.text.isNotBlank() && token.text.isNotBlank() &&
(url.text.startsWith("http://") || url.text.startsWith("https://")),
onClick = {
vm.saveSettings(Settings(url, token, author))
vm.saveSettings(Settings(url.text, token.text, author.text))
onDone()
},
shape = RoundedCornerShape(14.dp),
) { Text("Salvar e continuar") }
}
}
}
}
/** Tela principal: lista local de notas + banner de sincronização. */
@OptIn(ExperimentalMaterial3Api::class)
/* ==================================================================== Lista */
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun NotesListScreen(
vm: NotasViewModel,
@@ -118,163 +191,497 @@ fun NotesListScreen(
val notes by vm.notes.collectAsState()
val sync by vm.syncState.collectAsState()
val cats by vm.categories.collectAsState()
val catNames = remember(cats) { cats.associate { it.id to it.name } }
val presence by vm.presence.collectAsState()
val remoteChanged by vm.remoteChanged.collectAsState()
var filterCat by remember { mutableStateOf<String?>(null) }
var filterTag by remember { mutableStateOf<String?>(null) }
val snackbar = remember { SnackbarHostState() }
val catPaths = remember(cats) {
val byId = cats.associateBy { it.id }
fun path(id: String): String {
val parts = mutableListOf<String>()
var cur = byId[id]
var guard = 0
while (cur != null && guard++ < 10) {
parts.add(0, cur.name)
cur = byId[cur.parent]
}
return parts.joinToString("/")
}
cats.associate { it.id to path(it.id) }
}
val allTags = remember(notes) {
notes.flatMap { it.tags.split(",").filter { t -> t.isNotBlank() } }.distinct().sorted()
}
val shown = remember(notes, filterCat, filterTag) {
notes.filter { n ->
(filterCat == null || n.categories.split(",").contains(filterCat)) &&
(filterTag == null || n.tags.split(",").contains(filterTag))
}
}
val events by vm.events.collectAsState()
LaunchedEffect(events) {
if (events is ListEvent.RemoteChange) {
snackbar.showSnackbar("“${(events as ListEvent.RemoteChange).title}” foi atualizada pelo servidor")
vm.consumeEvent()
}
}
Scaffold(
snackbarHost = { SnackbarHost(snackbar) },
topBar = {
TopAppBar(
MediumTopAppBar(
title = { Text("Notas") },
actions = {
TextButton(onClick = { vm.syncNow() }) { Text("Sincronizar") }
TextButton(onClick = onSettings) { Text("Servidor") }
IconButton(onClick = { vm.syncNow() }) {
if (sync.phase == SyncPhase.Syncing) {
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
} else {
Icon(Icons.Default.CloudSync, contentDescription = "Sincronizar agora")
}
}
IconButton(onClick = onSettings) {
Icon(Icons.Default.Description, contentDescription = "Servidor")
}
},
colors = TopAppBarDefaults.mediumTopAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
),
)
},
floatingActionButton = {
androidx.compose.material3.FloatingActionButton(onClick = onNew) {
Icon(Icons.Default.Add, contentDescription = "Nova nota")
}
ExtendedFloatingActionButton(
onClick = onNew,
icon = { Icon(Icons.Default.Add, null) },
text = { Text("Nova") },
)
},
) { pad ->
Column(modifier = Modifier.padding(pad).fillMaxSize()) {
SyncBanner(sync)
if (notes.isEmpty()) {
Text(
"Nenhuma nota local.\nToque em + para criar (funciona offline).",
modifier = Modifier.padding(24.dp),
style = MaterialTheme.typography.bodyMedium,
)
SyncBanner(sync, Modifier.padding(horizontal = 12.dp))
if (cats.isNotEmpty() || allTags.isNotEmpty()) {
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState())
.padding(horizontal = 12.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
FilterChip(filterCat == null && filterTag == null, { filterCat = null; filterTag = null },
label = { Text("Todas") })
cats.take(12).forEach { c ->
FilterChip(filterCat == c.id, { filterCat = c.id; filterTag = null },
label = { Text("📁 " + (catPaths[c.id] ?: c.name)) })
}
allTags.take(12).forEach { t ->
FilterChip(filterTag == t, { filterTag = t; filterCat = null },
label = { Text("#$t") })
}
}
}
if (shown.isEmpty()) {
Box(Modifier.fillMaxSize().weight(1f), contentAlignment = Alignment.Center) {
Text(
if (notes.isEmpty()) "Nenhuma nota ainda.\nToque em “Nova” — funciona offline."
else "Nada com esse filtro.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(8.dp),
contentPadding = PaddingValues(start = 12.dp, end = 12.dp, bottom = 96.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(notes, key = { it.id }) { note ->
androidx.compose.material3.Card(
items(shown, key = { it.id }) { note ->
NoteCard(
note = note,
catPaths = catPaths,
others = presence[note.id] ?: 0,
remotelyChanged = remoteChanged.contains(note.id),
onClick = { onOpen(note.id) },
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
Row(
modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(Modifier.weight(1f)) {
Text(
(note.title.ifBlank { "Sem título" }) +
when (note.format) { "md" -> ".md"; "html" -> ".html"; else -> ".txt" },
style = MaterialTheme.typography.titleSmall,
)
val bits = mutableListOf(
java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT)
.format(java.util.Date(note.updatedAt)),
)
note.categories.split(",").filter { it.isNotBlank() }
.map { catNames[it] ?: it }.ifNotEmpty()?.let { bits.add(it.joinToString(" · ")) }
if (note.tags.isNotBlank()) bits.add(note.tags.split(",").joinToString(" ") { "#$it" })
Text(bits.joinToString(" · "), style = MaterialTheme.typography.bodySmall)
if (note.conflictNote.isNotBlank()) {
Text("⚠ ${note.conflictNote}", style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error)
}
}
Column(horizontalAlignment = androidx.compose.ui.Alignment.End) {
if (note.dirty) Text("● não enviado", color = MaterialTheme.colorScheme.tertiary,
style = MaterialTheme.typography.labelSmall)
if (note.pendingDelete) Text("🗑 fila", style = MaterialTheme.typography.labelSmall)
}
}
}
)
}
}
}
}
}
private fun <T> List<T>.ifNotEmpty(): List<T>? = if (isEmpty()) null else this
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
private fun NoteCard(
note: NoteEntity,
catPaths: Map<String, String>,
others: Int,
remotelyChanged: Boolean,
onClick: () -> Unit,
) {
Card(onClick = onClick, shape = RoundedCornerShape(18.dp), elevation = CardDefaults.cardElevation(2.dp)) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
(note.title.ifBlank { "Sem título" }) + extOf(note.format),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
maxLines = 1,
)
when {
note.pendingDelete -> StatusDot(Color(0xFFE01B24), "exclusão na fila")
note.dirty -> StatusDot(Color(0xFFE5A50A), "não enviado")
else -> Icon(Icons.Default.CloudDone, "sincronizado",
Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary)
}
if (others > 0) {
Spacer(Modifier.width(6.dp))
Text("👥$others", style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary)
}
}
Text(
ago(note.updatedAt) + if (note.tags.isNotBlank())
" · " + note.tags.split(",").filter { it.isNotBlank() }.joinToString(" ") { "#$it" } else "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
val catList = note.categories.split(",").filter { it.isNotBlank() }
if (catList.isNotEmpty() || remotelyChanged || note.conflictNote.isNotBlank()) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)) {
catList.forEach { id ->
AssistChip(onClick = onClick, label = {
Text("📁 " + (catPaths[id] ?: id), style = MaterialTheme.typography.labelSmall)
})
}
if (remotelyChanged) WarnChip("mudou remotamente")
if (note.conflictNote.isNotBlank()) WarnChip("conflito resolvido")
}
}
}
}
}
@Composable
private fun WarnChip(text: String) {
Surface(color = MaterialTheme.colorScheme.tertiaryContainer, shape = RoundedCornerShape(50)) {
Row(Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Icon(Icons.Default.Warning, null, Modifier.size(12.dp))
Text(text, style = MaterialTheme.typography.labelSmall)
}
}
}
@Composable
private fun StatusDot(color: Color, desc: String) {
Box(
Modifier.size(12.dp),
contentAlignment = Alignment.Center,
) {
Surface(color = color, shape = androidx.compose.foundation.shape.CircleShape,
modifier = Modifier.size(10.dp)) { Text("", Modifier.size(10.dp)) }
}
// descrição para acessibilidade via contentDescription do item pai não é crítica aqui
}
/* ================================================================== Editor */
private enum class EditorMode { Edit, Wysiwyg, Preview }
/** Editor offline-first: autosave local após 10 s de ociosidade + salvar explícito. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditorScreen(vm: NotasViewModel, noteId: String, onBack: () -> Unit) {
var loaded by remember { mutableStateOf(false) }
var title by remember { mutableStateOf("") }
var title by remember { mutableStateOf(TextFieldValue("")) }
var format by remember { mutableStateOf("md") }
var content by remember { mutableStateOf("") }
var body by remember { mutableStateOf(TextFieldValue("")) }
var selectedCats by remember { mutableStateOf<List<String>>(emptyList()) }
var savedAt by remember { mutableStateOf("") }
var mode by remember { mutableStateOf(EditorMode.Edit) }
val cats by vm.categories.collectAsState()
val remoteChanged by vm.remoteChanged.collectAsState()
val presence by vm.presence.collectAsState()
val controller = remember { WysiwygController() }
val keyboard = LocalSoftwareKeyboardController.current
val dark = isSystemInDarkTheme()
LaunchedEffect(noteId) {
vm.noteById(noteId)?.let {
title = it.title
title = TextFieldValue(it.title)
format = it.format
content = it.content
body = TextFieldValue(it.content)
selectedCats = it.categories.split(",").filter { c -> c.isNotBlank() }
}
loaded = true
}
LaunchedEffect(noteId, loaded) { vm.openNoteId = noteId }
androidx.compose.runtime.DisposableEffect(Unit) {
onDispose { vm.openNoteId = null }
}
// SSE: nota limpa aberta mudou no servidor → atualiza o corpo ao vivo
val liveRefresh by vm.editorRefresh.collectAsState()
LaunchedEffect(liveRefresh) {
val r = liveRefresh ?: return@LaunchedEffect
if (r.first == noteId) {
body = TextFieldValue(r.second)
vm.editorRefresh.value = null
}
}
fun persist() {
if (!loaded) return
vm.saveNote(noteId, title, format, selectedCats, content)
savedAt = java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT)
.format(java.util.Date())
vm.saveNote(noteId, title.text, format, selectedCats, body.text)
savedAt = java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT).format(java.util.Date())
}
// ociosidade: o efeito é reiniciado a cada alteração; só grava se
// passarem 10 s sem nenhuma (cada tecla cancela o delay anterior)
LaunchedEffect(title, format, content, loaded) {
if (!loaded) return@LaunchedEffect
// WYSIWYG: ponte JS avisa mudança → Kotlin busca o markdown (debounce)
var changeTick by remember { mutableStateOf(0) }
LaunchedEffect(controller) {
controller.onChanged = { changeTick++ }
}
LaunchedEffect(changeTick) {
if (changeTick == 0) return@LaunchedEffect
delay(900)
controller.requestMd { md ->
if (md != body.text) {
body = TextFieldValue(md)
persist()
}
}
}
// autosave por ociosidade no modo texto
LaunchedEffect(title, body, loaded) {
if (!loaded || mode != EditorMode.Edit) return@LaunchedEffect
delay(AUTOSAVE_IDLE_MS)
persist()
}
// ao entrar em WYSIWYG/Preview, (re)renderiza o html
LaunchedEffect(mode, loaded) {
if (!loaded || mode == EditorMode.Edit) return@LaunchedEffect
if (format == "html") controller.setHtml(body.text)
else vm.renderMarkdown(body.text) { html -> controller.setHtml(html) }
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(title.ifBlank { "Nova nota" }) },
navigationIcon = { TextButton(onClick = { persist(); onBack() }) { Text("←") } },
androidx.compose.material3.TopAppBar(
navigationIcon = {
IconButton(onClick = { persist(); onBack() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Voltar")
}
},
title = {
OutlinedTextField(
value = title, onValueChange = { title = it; },
placeholder = { Text("Título") },
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent, unfocusedBorderColor = Color.Transparent),
modifier = Modifier.width(240.dp),
)
},
actions = {
Text(savedAt.ifBlank { "—" }, style = MaterialTheme.typography.labelSmall)
TextButton(onClick = { persist(); keyboard?.hide() }) { Text("Salvar") }
if ((presence[noteId] ?: 0) > 0) Text("👥", Modifier.padding(end = 6.dp))
Text(
savedAt.ifBlank { if (loaded) "—" else "" },
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
IconButton(onClick = { persist(); keyboard?.hide() }) {
Icon(Icons.Default.CloudDone, "Salvar")
}
},
)
},
) { pad ->
Column(modifier = Modifier.padding(pad).fillMaxSize().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(value = title, onValueChange = { title = it },
label = { Text("Título") }, singleLine = true, modifier = Modifier.fillMaxWidth())
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("txt", "md", "html").forEach { f ->
FilterChip(selected = format == f, onClick = { format = f }, label = { Text(f.uppercase()) })
Column(modifier = Modifier.padding(pad).fillMaxSize()) {
if (remoteChanged.contains(noteId)) {
Surface(color = MaterialTheme.colorScheme.tertiaryContainer,
shape = RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp)) {
Row(Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Mudou no servidor.", style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f))
TextButton(onClick = {
vm.reloadFromServer(noteId) { n ->
n?.let { body = TextFieldValue(it.content) }
}
}) { Text("Recarregar") }
TextButton(onClick = { vm.dismissRemoteChange(noteId) }) { Text("Manter") }
}
}
}
if (cats.isNotEmpty()) {
Text("Categorias", style = MaterialTheme.typography.labelMedium)
// seletor de modo + formato
Row(
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ModeChip(mode == EditorMode.Edit, Icons.Default.Edit, "Editar") { mode = EditorMode.Edit }
if (format != "txt") {
ModeChip(mode == EditorMode.Wysiwyg, Icons.Default.Description, "WYSIWYG") { mode = EditorMode.Wysiwyg }
ModeChip(mode == EditorMode.Preview, Icons.Default.Visibility, "Preview") { mode = EditorMode.Preview }
}
Spacer(Modifier.weight(1f))
listOf("txt", "md", "html").forEach { f ->
FilterChip(selected = format == f, onClick = { format = f },
label = { Text(f.uppercase(), style = MaterialTheme.typography.labelSmall) })
}
}
if (cats.isNotEmpty() && mode == EditorMode.Edit) {
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState())
.padding(horizontal = 12.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
cats.forEach { c ->
cats.take(14).forEach { c ->
FilterChip(
selected = selectedCats.contains(c.id),
onClick = {
selectedCats = if (selectedCats.contains(c.id))
selectedCats - c.id else selectedCats + c.id
},
label = { Text(c.name) },
label = { Text(c.name, style = MaterialTheme.typography.labelSmall) },
)
}
}
}
OutlinedTextField(
value = content, onValueChange = { content = it },
modifier = Modifier.fillMaxWidth().weight(1f),
textStyle = androidx.compose.ui.text.TextStyle(fontFamily = FontFamily.Monospace),
placeholder = { Text("Escreva… #tags viram tags no servidor") },
)
when (mode) {
EditorMode.Edit -> {
OutlinedTextField(
value = body,
onValueChange = { body = it },
placeholder = { Text("Escreva… #tags viram tags no servidor") },
modifier = Modifier.fillMaxWidth().weight(1f).padding(horizontal = 12.dp),
shape = RoundedCornerShape(16.dp),
textStyle = TextStyle(fontFamily = FontFamily.Monospace, fontSize = MaterialTheme.typography.bodyLarge.fontSize),
)
MdToolbar(
enabled = format != "txt",
onCmd = { cmd ->
body = when (cmd) {
"bold" -> wrap(body, "**", "**", "negrito")
"italic" -> wrap(body, "*", "*", "itálico")
"strike" -> wrap(body, "~~", "~~", "tachado")
"code" -> wrap(body, "`", "`", "código")
"h1" -> prefix(body, "# ")
"h2" -> prefix(body, "## ")
"ul" -> prefix(body, "- ")
"quote" -> prefix(body, "> ")
"link" -> wrap(body, "[", "](https://)", "link")
"block" -> wrap(body, "\n```\n", "\n```\n", "código em bloco")
else -> body
}
},
)
}
EditorMode.Wysiwyg -> {
EditorWebView(controller, dark, Modifier.fillMaxWidth().weight(1f))
WysiwygToolbar(controller, format)
}
EditorMode.Preview -> {
EditorWebView(controller, dark, Modifier.fillMaxWidth().weight(1f))
}
}
}
}
}
@Composable
private fun ModeChip(selected: Boolean, icon: ImageVector, label: String, onClick: () -> Unit) {
FilterChip(selected = selected, onClick = onClick,
leadingIcon = { Icon(icon, null, Modifier.size(16.dp)) },
label = { Text(label, style = MaterialTheme.typography.labelMedium) })
}
@Composable
private fun MdToolbar(enabled: Boolean, onCmd: (String) -> Unit) {
val items = listOf(
"B" to "bold", "I" to "italic", "S" to "strike", "H1" to "h1", "H2" to "h2",
"•" to "ul", "❝" to "quote", "🔗" to "link", "</>" to "code", "{ }" to "block",
)
Surface(
tonalElevation = 3.dp,
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
) {
Row(
Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
items.forEach { (label, cmd) ->
TextButton(
onClick = { if (enabled) onCmd(cmd) },
enabled = enabled,
contentPadding = androidx.compose.foundation.layout.PaddingValues(8.dp, 4.dp),
) { Text(label, fontWeight = FontWeight.Bold) }
}
}
}
}
@Composable
private fun WysiwygToolbar(controller: WysiwygController, format: String) {
Surface(tonalElevation = 3.dp, modifier = Modifier.fillMaxWidth()) {
Row(
Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
val cmds: List<Pair<String, () -> Unit>> = if (format == "html") listOf(
"B" to { controller.exec("bold") },
"I" to { controller.exec("italic") },
"S" to { controller.exec("strikeThrough") },
"H1" to { controller.exec("formatBlock", "h1") },
"H2" to { controller.exec("formatBlock", "h2") },
"•" to { controller.exec("insertUnorderedList") },
"1." to { controller.exec("insertOrderedList") },
"❝" to { controller.exec("formatBlock", "blockquote") },
"🔗" to { controller.exec("createLink", "https://") },
"</>" to { controller.exec("insertHTML", "<code>código</code>") },
) else listOf(
"B" to { controller.exec("bold") },
"I" to { controller.exec("italic") },
"S" to { controller.exec("strikeThrough") },
"H1" to { controller.exec("formatBlock", "h1") },
"H2" to { controller.exec("formatBlock", "h2") },
"•" to { controller.exec("insertUnorderedList") },
"1." to { controller.exec("insertOrderedList") },
"❝" to { controller.exec("formatBlock", "blockquote") },
"🔗" to { controller.exec("createLink", "https://") },
"</>" to { controller.exec("insertHTML", "<code>código</code>") },
"{ }" to { controller.exec("insertHTML", "<pre><code>bloco</code></pre>") },
)
cmds.forEach { (label, act) ->
TextButton(onClick = act, contentPadding = androidx.compose.foundation.layout.PaddingValues(8.dp, 4.dp)) {
Text(label, fontWeight = FontWeight.Bold)
}
}
}
}
}
// ---- helpers de markdown no TextField ----
private fun wrap(v: TextFieldValue, before: String, after: String, placeholder: String): TextFieldValue {
val sel = v.selection
val text = if (sel.length > 0) v.text.substring(sel.start, sel.end) else placeholder
val out = v.text.substring(0, sel.start) + before + text + after + v.text.substring(sel.end)
val start = sel.start + before.length
return TextFieldValue(out, TextRange(start, start + text.length))
}
private fun prefix(v: TextFieldValue, prefixStr: String): TextFieldValue {
val start = v.text.lastIndexOf('\n', (v.selection.start - 1).coerceAtLeast(0)) + 1
var end = v.text.indexOf('\n', v.selection.end)
if (end < 0) end = v.text.length
val block = v.text.substring(start, end)
val newBlock = block.split("\n").joinToString("\n") { line ->
if (line.startsWith(prefixStr)) line.removePrefix(prefixStr) else prefixStr + line
}
val out = v.text.substring(0, start) + newBlock + v.text.substring(end)
val caret = start + newBlock.length
return TextFieldValue(out, TextRange(caret, caret))
}
@@ -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>
"""