Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61d4abebbd |
No files matched your search
@@ -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+='||'')+')';}
|
||||
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>
|
||||
"""
|
||||
Reference in new issue
Block a user