diff --git a/README.md b/README.md index 45e1aba..93f3707 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/src/main/java/com/labexposed/notas/MainActivity.kt b/app/src/main/java/com/labexposed/notas/MainActivity.kt index 0d320ff..ec9ed82 100644 --- a/app/src/main/java/com/labexposed/notas/MainActivity.kt +++ b/app/src/main/java/com/labexposed/notas/MainActivity.kt @@ -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 } } }) diff --git a/app/src/main/java/com/labexposed/notas/data/remote/ApiClient.kt b/app/src/main/java/com/labexposed/notas/data/remote/ApiClient.kt index 4e529f8..4ee67a3 100644 --- a/app/src/main/java/com/labexposed/notas/data/remote/ApiClient.kt +++ b/app/src/main/java/com/labexposed/notas/data/remote/ApiClient.kt @@ -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 = call("DELETE", "/api/notes/$id") + + fun renderMd(content: String): ApiResult { + val body = """{"content":${json.encodeToString(content)},"format":"md"}""" + val r = call("POST", "/api/render", body) + return if (r is ApiResult.Ok) ApiResult.Ok((r.value as RenderDto).html) else r as ApiResult + } } diff --git a/app/src/main/java/com/labexposed/notas/data/remote/Dtos.kt b/app/src/main/java/com/labexposed/notas/data/remote/Dtos.kt index 646477c..f0e8f7c 100644 --- a/app/src/main/java/com/labexposed/notas/data/remote/Dtos.kt +++ b/app/src/main/java/com/labexposed/notas/data/remote/Dtos.kt @@ -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 { data class Ok(val value: T) : ApiResult() @@ -69,3 +74,18 @@ sealed class ApiResult { data class Network(val message: String) : ApiResult() data class Server(val code: Int, val message: String) : ApiResult() } + +// ---- 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, +) diff --git a/app/src/main/java/com/labexposed/notas/data/remote/SseClient.kt b/app/src/main/java/com/labexposed/notas/data/remote/SseClient.kt new file mode 100644 index 0000000..d5e8830 --- /dev/null +++ b/app/src/main/java/com/labexposed/notas/data/remote/SseClient.kt @@ -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") + } +} diff --git a/app/src/main/java/com/labexposed/notas/data/remote/SseEvent.kt b/app/src/main/java/com/labexposed/notas/data/remote/SseEvent.kt new file mode 100644 index 0000000..ae27424 --- /dev/null +++ b/app/src/main/java/com/labexposed/notas/data/remote/SseEvent.kt @@ -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? = 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? = 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) : SseEvent() + + companion object { + fun parse(raw: String): SseEvent? { + val dto = json.decodeFromString(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 + } + } + } +} diff --git a/app/src/main/java/com/labexposed/notas/domain/SessionId.kt b/app/src/main/java/com/labexposed/notas/domain/SessionId.kt new file mode 100644 index 0000000..65eb62d --- /dev/null +++ b/app/src/main/java/com/labexposed/notas/domain/SessionId.kt @@ -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 + } +} diff --git a/app/src/main/java/com/labexposed/notas/domain/SyncEngine.kt b/app/src/main/java/com/labexposed/notas/domain/SyncEngine.kt index 1b77fbd..b535140 100644 --- a/app/src/main/java/com/labexposed/notas/domain/SyncEngine.kt +++ b/app/src/main/java/com/labexposed/notas/domain/SyncEngine.kt @@ -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}" diff --git a/app/src/main/java/com/labexposed/notas/sync/SyncWorker.kt b/app/src/main/java/com/labexposed/notas/sync/SyncWorker.kt index a7c4158..8713933 100644 --- a/app/src/main/java/com/labexposed/notas/sync/SyncWorker.kt +++ b/app/src/main/java/com/labexposed/notas/sync/SyncWorker.kt @@ -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() diff --git a/app/src/main/java/com/labexposed/notas/ui/NotasViewModel.kt b/app/src/main/java/com/labexposed/notas/ui/NotasViewModel.kt index ac05f3a..f8df839 100644 --- a/app/src/main/java/com/labexposed/notas/ui/NotasViewModel.kt +++ b/app/src/main/java/com/labexposed/notas/ui/NotasViewModel.kt @@ -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(null) val events: StateFlow = _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()) + + 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>(emptyMap()) + + /** notas sujas que mudaram remotamente (banner no editor). */ + val remoteChanged = MutableStateFlow>(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?>(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() + 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("

…sem conexão para renderizar…

") + } + 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)) diff --git a/app/src/main/java/com/labexposed/notas/ui/Screens.kt b/app/src/main/java/com/labexposed/notas/ui/Screens.kt index 687b09f..d13b3f6 100644 --- a/app/src/main/java/com/labexposed/notas/ui/Screens.kt +++ b/app/src/main/java/com/labexposed/notas/ui/Screens.kt @@ -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(null) } + var filterTag by remember { mutableStateOf(null) } + val snackbar = remember { SnackbarHostState() } + + val catPaths = remember(cats) { + val byId = cats.associateBy { it.id } + fun path(id: String): String { + val parts = mutableListOf() + 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 List.ifNotEmpty(): List? = if (isEmpty()) null else this +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +private fun NoteCard( + note: NoteEntity, + catPaths: Map, + 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>(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 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", "código") }, + ) 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", "código") }, + "{ }" to { controller.exec("insertHTML", "
bloco
") }, + ) + 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)) +} diff --git a/app/src/main/java/com/labexposed/notas/ui/Wysiwyg.kt b/app/src/main/java/com/labexposed/notas/ui/Wysiwyg.kt new file mode 100644 index 0000000..ed2f4f8 --- /dev/null +++ b/app/src/main/java/com/labexposed/notas/ui/Wysiwyg.kt @@ -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 = """ + + + +
+ +"""