Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8561ae2f8b | ||
|
|
34a6c38540 | ||
|
|
57edfbe4df | ||
|
|
9508dbf24a | ||
|
|
400ab066b2 | ||
|
|
61d4abebbd |
No files matched your search
@@ -1,5 +1,6 @@
|
|||||||
*.iml
|
*.iml
|
||||||
.gradle/
|
.gradle/
|
||||||
|
.kotlin/
|
||||||
build/
|
build/
|
||||||
local.properties
|
local.properties
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
@@ -90,20 +90,38 @@ notas-online-android/
|
|||||||
rebaixadas (GET completo); notas que sumiram do servidor (e não estão na fila) são
|
rebaixadas (GET completo); notas que sumiram do servidor (e não estão na fila) são
|
||||||
removidas localmente.
|
removidas localmente.
|
||||||
5. **Gatilhos**: WorkManager periódico (1h, exige rede, backoff exponencial) · rede
|
5. **Gatilhos**: WorkManager periódico (1h, exige rede, backoff exponencial) · rede
|
||||||
restabelecida (`ConnectivityManager` callback) · após cada edição com conteúdo alterado ·
|
restabelecida (`ConnectivityManager` callback) · **a cada edição com autosave (10 s de
|
||||||
botão **Sincronizar** · troca de configuração.
|
ociosidade), incluindo mudanças só de título/formato/categorias** · **ao voltar para o app**
|
||||||
6. **Autosave do editor**: grava local + enfileira após **10 s de ociosidade** (cada tecla
|
(`ON_RESUME`) · botão **Sincronizar** · troca de configuração. Uma auto-reconciliação a cada
|
||||||
reinicia a contagem) — igual à política web.
|
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
|
- **Lista**: `MediumTopAppBar`, banner de status colorido (Offline/Sincronizando/Sincronizado±
|
||||||
notas via `GET /api/notes`) antes de salvar.
|
pendências/Erro), chips de filtro (Todas / 📁 categoria com caminho da árvore / #tag), cards
|
||||||
- **Lista**: banner de status colorido — cinza *não configurado*, cinza-escuro *Offline (N na
|
com título+extensão, tempo relativo, tags, chip de categoria(s) e ícone de status por nota
|
||||||
fila)*, azul *Sincronizando…*, verde *Sincronizado às HH:mm · N pendente(s) · ⚠ conflitos*,
|
(☁✓ sincronizada, ● âmbar não enviada, 🗑 exclusão na fila, ⚠ conflito/mudança remota, 👥N
|
||||||
vermelho *erro/401*; itens mostram ● não enviado / 🗑 fila / ⚠ nota de conflito.
|
presença), **botão de excluir por card** (🗑) com diálogo de confirmação, FAB estendido "Nova".
|
||||||
- **Editor**: título, chips de formato (TXT/MD/HTML), chips multi-seleção de categorias
|
- **Editor**: campo de título na top bar, seletor de modo (Editar / WYSIWYG / Preview), chips de
|
||||||
(árvore espelhada do servidor), corpo monoespaçado; autosave ocioso + botão Salvar.
|
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 👥,
|
||||||
|
horário do último salvamento e **botão de excluir** (com confirmação; volta para a lista).
|
||||||
|
- **Exclusões sincronizam**: apagar marca `pendingDelete`, entra `delete` na fila e sobe na
|
||||||
|
próxima sync (nota local-only some direto); outras sessões recebem o evento SSE `deleted` e a
|
||||||
|
nota some delas também se estiver limpa. Se a nota já sumiu do servidor (404), a fila limpa
|
||||||
|
sozinha.
|
||||||
|
- **Servidor**: URL + token + autor com *Testar conexão* e validação de esquema http(s).
|
||||||
|
|
||||||
## Compilar e instalar
|
## Compilar e instalar
|
||||||
|
|
||||||
@@ -128,16 +146,22 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk
|
|||||||
- criar nota offline → autosave por ociosidade (10 s) → `POST` único (sem duplicatas);
|
- 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`);
|
- editar offline → fila resiste à queda de rede → push automático ao restabelecer (`ConnectivityManager`);
|
||||||
- nota criada na Web aparece no device via pull;
|
- 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
|
- **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)"
|
adota o servidor e o texto do Android vira exatamente **uma** cópia "… (conflito)"
|
||||||
(idempotente; sincronizações serializadas por mutex + índices únicos na outbox).
|
(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).
|
- 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
|
`/api/events`, `/api/presence`). O servidor normaliza `categories`/`tags` vazios como `[]`
|
||||||
tipos não-nuláveis, ex. kotlinx.serialization).
|
(null quebrava clientes com tipos não-nuláveis, ex. kotlinx.serialization).
|
||||||
|
|
||||||
## Roadmap honesto (não implementado aqui)
|
## 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).
|
- 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.
|
- Multi-perfil/servidores e sincronização de anexos.
|
||||||
@@ -13,9 +13,8 @@ android {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.labexposed.notas"
|
applicationId = "com.labexposed.notas"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
versionCode = 4
|
||||||
versionCode = 1
|
versionName = "0.2.2"
|
||||||
versionName = "1.0.0"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|||||||
@@ -20,9 +20,11 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
import com.labexposed.notas.domain.SyncPhase
|
import com.labexposed.notas.domain.SyncPhase
|
||||||
import com.labexposed.notas.domain.SyncUiState
|
import com.labexposed.notas.domain.SyncUiState
|
||||||
import com.labexposed.notas.ui.EditorScreen
|
import com.labexposed.notas.ui.EditorScreen
|
||||||
@@ -45,6 +47,9 @@ private fun NotasApp_(vm: NotasViewModel = viewModel()) {
|
|||||||
val nav = rememberNavController()
|
val nav = rememberNavController()
|
||||||
val settings by vm.settings.collectAsState()
|
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") {
|
NavHost(navController = nav, startDestination = if (settings.configured) "list" else "settings") {
|
||||||
composable("settings") {
|
composable("settings") {
|
||||||
SettingsScreen(vm, onDone = { nav.navigate("list") { popUpTo("settings") { inclusive = true } } })
|
SettingsScreen(vm, onDone = { nav.navigate("list") { popUpTo("settings") { inclusive = true } } })
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import android.content.Context
|
|||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [NoteEntity::class, OutboxEntity::class, CategoryEntity::class, MetaEntity::class],
|
entities = [NoteEntity::class, OutboxEntity::class, CategoryEntity::class, MetaEntity::class],
|
||||||
version = 2,
|
version = 3,
|
||||||
exportSchema = false,
|
exportSchema = false,
|
||||||
)
|
)
|
||||||
abstract class NotasDatabase : RoomDatabase() {
|
abstract class NotasDatabase : RoomDatabase() {
|
||||||
@@ -20,6 +22,17 @@ abstract class NotasDatabase : RoomDatabase() {
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var instance: NotasDatabase? = null
|
private var instance: NotasDatabase? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1→v2→v3: a mudança real foi o índice único (noteId, op) na outbox
|
||||||
|
* (v2). Migração recria o índice sem tocar nos dados — a fila não
|
||||||
|
* enviada sobrevive ao upgrade, ao contrário do destructive fallback.
|
||||||
|
*/
|
||||||
|
val MIGRATION_2_3: Migration = object : Migration(2, 3) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_outbox_noteId_op` ON `outbox` (`noteId`, `op`)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun get(context: Context): NotasDatabase =
|
fun get(context: Context): NotasDatabase =
|
||||||
instance ?: synchronized(this) {
|
instance ?: synchronized(this) {
|
||||||
instance ?: Room.databaseBuilder(
|
instance ?: Room.databaseBuilder(
|
||||||
@@ -27,7 +40,7 @@ abstract class NotasDatabase : RoomDatabase() {
|
|||||||
NotasDatabase::class.java,
|
NotasDatabase::class.java,
|
||||||
"notas.db",
|
"notas.db",
|
||||||
)
|
)
|
||||||
.fallbackToDestructiveMigration() // v1→v2: dados de teste; em produção usar migração real
|
.addMigrations(MIGRATION_2_3)
|
||||||
.build().also { instance = it }
|
.build().also { instance = it }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.labexposed.notas.data.remote
|
package com.labexposed.notas.data.remote
|
||||||
|
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
@@ -8,7 +9,6 @@ import okhttp3.Request
|
|||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cliente HTTP da API notas-online: Bearer token, timeout curto, retry com
|
* Cliente HTTP da API notas-online: Bearer token, timeout curto, retry com
|
||||||
* backoff apenas para falhas de rede (IOException), mapeamento de status em
|
* backoff apenas para falhas de rede (IOException), mapeamento de status em
|
||||||
@@ -29,7 +29,7 @@ class ApiClient(private val baseUrl: String, private val token: String) {
|
|||||||
private fun url(path: String) = baseUrl.trimEnd('/') + path
|
private fun url(path: String) = baseUrl.trimEnd('/') + path
|
||||||
|
|
||||||
/** Executa com até 2 tentativas; erros de rede/URL inválida viram Network. */
|
/** Executa com até 2 tentativas; erros de rede/URL inválida viram Network. */
|
||||||
private inline fun <reified T> call(
|
private suspend inline fun <reified T> call(
|
||||||
method: String,
|
method: String,
|
||||||
path: String,
|
path: String,
|
||||||
body: String? = null,
|
body: String? = null,
|
||||||
@@ -65,7 +65,7 @@ class ApiClient(private val baseUrl: String, private val token: String) {
|
|||||||
}
|
}
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
lastError = e
|
lastError = e
|
||||||
if (attempt == 0) Thread.sleep(500)
|
if (attempt == 0) delay(500)
|
||||||
} catch (e: IllegalArgumentException) {
|
} catch (e: IllegalArgumentException) {
|
||||||
// URL malformada (configuração errada): não adianta repetir
|
// URL malformada (configuração errada): não adianta repetir
|
||||||
return ApiResult.Network("URL inválida: ${e.message}")
|
return ApiResult.Network("URL inválida: ${e.message}")
|
||||||
@@ -74,19 +74,25 @@ class ApiClient(private val baseUrl: String, private val token: String) {
|
|||||||
return ApiResult.Network(lastError?.message ?: "sem conexão")
|
return ApiResult.Network(lastError?.message ?: "sem conexão")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun testConnection(): ApiResult<List<NoteSummaryDto>> = call("GET", "/api/notes")
|
suspend fun testConnection(): ApiResult<List<NoteSummaryDto>> = call("GET", "/api/notes")
|
||||||
|
|
||||||
fun listNotes(): ApiResult<List<NoteSummaryDto>> = call("GET", "/api/notes")
|
suspend fun listNotes(): ApiResult<List<NoteSummaryDto>> = call("GET", "/api/notes")
|
||||||
|
|
||||||
fun listCategories(): ApiResult<List<CategoryDto>> = call("GET", "/api/categories")
|
suspend fun listCategories(): ApiResult<List<CategoryDto>> = call("GET", "/api/categories")
|
||||||
|
|
||||||
fun getNote(id: String): ApiResult<NoteDetailDto> = call("GET", "/api/notes/$id")
|
suspend fun getNote(id: String): ApiResult<NoteDetailDto> = call("GET", "/api/notes/$id")
|
||||||
|
|
||||||
fun createNote(dto: NoteWriteDto): ApiResult<NoteDetailDto> =
|
suspend fun createNote(dto: NoteWriteDto): ApiResult<NoteDetailDto> =
|
||||||
call("POST", "/api/notes", json.encodeToString(dto))
|
call("POST", "/api/notes", json.encodeToString(dto))
|
||||||
|
|
||||||
fun updateNote(id: String, dto: NoteWriteDto): ApiResult<NoteDetailDto> =
|
suspend fun updateNote(id: String, dto: NoteWriteDto): ApiResult<NoteDetailDto> =
|
||||||
call("PUT", "/api/notes/$id", json.encodeToString(dto))
|
call("PUT", "/api/notes/$id", json.encodeToString(dto))
|
||||||
|
|
||||||
fun deleteNote(id: String): ApiResult<Unit> = call("DELETE", "/api/notes/$id")
|
suspend fun deleteNote(id: String): ApiResult<Unit> = call("DELETE", "/api/notes/$id")
|
||||||
|
|
||||||
|
suspend fun renderMd(content: String): ApiResult<String> {
|
||||||
|
val body = """{"content":${json.encodeToString(content)},"format":"md"}"""
|
||||||
|
val r = call<RenderDto>("POST", "/api/render", body)
|
||||||
|
return if (r is ApiResult.Ok) ApiResult.Ok((r.value as RenderDto).html) else r as ApiResult<Nothing>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.labexposed.notas.data.remote
|
package com.labexposed.notas.data.remote
|
||||||
|
|
||||||
|
import com.labexposed.notas.data.local.NoteEntity
|
||||||
|
import java.time.Instant
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@@ -60,6 +62,9 @@ data class ConflictDto(
|
|||||||
@SerialName("updated_at") val updatedAt: String = "",
|
@SerialName("updated_at") val updatedAt: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RenderDto(val html: String = "")
|
||||||
|
|
||||||
/** Resultado tipado das chamadas — sem exceções de fluxo de controle. */
|
/** Resultado tipado das chamadas — sem exceções de fluxo de controle. */
|
||||||
sealed class ApiResult<out T> {
|
sealed class ApiResult<out T> {
|
||||||
data class Ok<T>(val value: T) : ApiResult<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 Network(val message: String) : ApiResult<Nothing>()
|
||||||
data class Server(val code: Int, val message: String) : ApiResult<Nothing>()
|
data class Server(val code: Int, val message: String) : ApiResult<Nothing>()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- mapeamento para a entidade local ----
|
||||||
|
|
||||||
|
fun NoteDetailDto.toNoteEntity(): NoteEntity = NoteEntity(
|
||||||
|
id = id,
|
||||||
|
title = title,
|
||||||
|
format = format,
|
||||||
|
categories = (categories ?: emptyList()).joinToString(","),
|
||||||
|
tags = (tags ?: emptyList()).joinToString(","),
|
||||||
|
content = content,
|
||||||
|
serverHash = hash,
|
||||||
|
createdAt = runCatching { Instant.parse(createdAt).toEpochMilli() }.getOrElse { System.currentTimeMillis() },
|
||||||
|
updatedAt = runCatching { Instant.parse(updatedAt).toEpochMilli() }.getOrElse { System.currentTimeMillis() },
|
||||||
|
dirty = false,
|
||||||
|
)
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package com.labexposed.notas.data.remote
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.job
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cliente SSE (Server-Sent Events) para eventos em tempo real do servidor:
|
||||||
|
* `saved` (nota mudou em outra sessão) e `presence` (quem está com o quê aberto).
|
||||||
|
* Reconexão com backoff; roda apenas com o app em primeiro plano — em
|
||||||
|
* background o WorkManager assume.
|
||||||
|
*/
|
||||||
|
class SseClient(private val baseUrl: String, private val token: String, private val sessionId: String) {
|
||||||
|
|
||||||
|
private val http = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(0, TimeUnit.MILLISECONDS) // stream longo
|
||||||
|
.build()
|
||||||
|
|
||||||
|
fun connect(scope: CoroutineScope, onEvent: (SseEvent) -> Unit): Job = scope.launch(Dispatchers.IO) {
|
||||||
|
var backoff = 1_000L
|
||||||
|
while (isActive) {
|
||||||
|
try {
|
||||||
|
streamOnce(onEvent)
|
||||||
|
backoff = 1_000L // stream terminou limpo (scope cancelado não chega aqui)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// rede caiu / servidor fora: espera e tenta de novo
|
||||||
|
}
|
||||||
|
delay(backoff)
|
||||||
|
backoff = (backoff * 2).coerceAtMost(30_000L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun streamOnce(onEvent: (SseEvent) -> Unit) {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(baseUrl.trimEnd('/') + "/api/events?session=" + sessionId)
|
||||||
|
.header("Authorization", "Bearer $token")
|
||||||
|
.header("Accept", "text/event-stream")
|
||||||
|
.build()
|
||||||
|
val call = http.newCall(request)
|
||||||
|
currentCoroutineContext().job.invokeOnCompletion { runCatching { call.cancel() } }
|
||||||
|
call.execute().use { resp ->
|
||||||
|
if (!resp.isSuccessful) throw java.io.IOException("SSE HTTP ${resp.code}")
|
||||||
|
val reader = resp.body?.source()?.inputStream()?.bufferedReader() ?: throw java.io.IOException("sem corpo")
|
||||||
|
var data = StringBuilder()
|
||||||
|
while (true) {
|
||||||
|
currentCoroutineContext().ensureActive()
|
||||||
|
val line = reader.readLine() ?: break
|
||||||
|
when {
|
||||||
|
line.startsWith("data: ") -> data.append(line.removePrefix("data: "))
|
||||||
|
line.startsWith("data:") -> data.append(line.removePrefix("data:").trimStart())
|
||||||
|
line.isEmpty() && data.isNotEmpty() -> {
|
||||||
|
val raw = data.toString()
|
||||||
|
data = StringBuilder()
|
||||||
|
runCatching { SseEvent.parse(raw) }.getOrNull()?.let(onEvent)
|
||||||
|
}
|
||||||
|
else -> { /* comentários ": ping" e campos outros */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw java.io.IOException("SSE encerrado")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.labexposed.notas.data.remote
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SsePresenceSession(val session: String, val notes: List<String>? = null)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SseEventDto(
|
||||||
|
val type: String = "",
|
||||||
|
@SerialName("note_id") val noteId: String = "",
|
||||||
|
val hash: String = "",
|
||||||
|
val title: String = "",
|
||||||
|
val author: String = "",
|
||||||
|
val session: String = "",
|
||||||
|
val deleted: Boolean = false,
|
||||||
|
val sessions: List<SsePresenceSession>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
sealed class SseEvent {
|
||||||
|
data class Saved(val noteId: String, val hash: String, val title: String, val session: String, val deleted: Boolean) : SseEvent()
|
||||||
|
data class Presence(val sessions: List<SsePresenceSession>) : SseEvent()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun parse(raw: String): SseEvent? {
|
||||||
|
val dto = json.decodeFromString<SseEventDto>(raw)
|
||||||
|
return when (dto.type) {
|
||||||
|
"saved" -> Saved(dto.noteId, dto.hash, dto.title, dto.session, dto.deleted)
|
||||||
|
"presence", "hello" -> Presence(dto.sessions ?: emptyList())
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.labexposed.notas.domain
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
/** Id de sessão estável por instalação — usado em presence/SSE e no eco de `session`. */
|
||||||
|
object SessionId {
|
||||||
|
fun get(context: Context): String {
|
||||||
|
val p = context.getSharedPreferences("notas", Context.MODE_PRIVATE)
|
||||||
|
var s = p.getString("sid", null)
|
||||||
|
if (s.isNullOrEmpty()) {
|
||||||
|
s = java.util.UUID.randomUUID().toString()
|
||||||
|
p.edit().putString("sid", s).apply()
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import com.labexposed.notas.data.remote.ApiClient
|
|||||||
import com.labexposed.notas.data.remote.ApiResult
|
import com.labexposed.notas.data.remote.ApiResult
|
||||||
import com.labexposed.notas.data.remote.NoteDetailDto
|
import com.labexposed.notas.data.remote.NoteDetailDto
|
||||||
import com.labexposed.notas.data.remote.NoteWriteDto
|
import com.labexposed.notas.data.remote.NoteWriteDto
|
||||||
import java.time.Instant
|
import com.labexposed.notas.data.remote.toNoteEntity
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
@@ -37,6 +37,7 @@ class SyncEngine(
|
|||||||
private val db: NotasDatabase,
|
private val db: NotasDatabase,
|
||||||
private val settings: SettingsRepository,
|
private val settings: SettingsRepository,
|
||||||
private val status: SyncStatusStore,
|
private val status: SyncStatusStore,
|
||||||
|
private val session: String = "android",
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private suspend fun api(): ApiClient? {
|
private suspend fun api(): ApiClient? {
|
||||||
@@ -50,6 +51,8 @@ class SyncEngine(
|
|||||||
SyncStatusStore.syncMutex.withLock {
|
SyncStatusStore.syncMutex.withLock {
|
||||||
try {
|
try {
|
||||||
runSync(trigger)
|
runSync(trigger)
|
||||||
|
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.e("SyncEngine", "fullSync($trigger) falhou", e)
|
android.util.Log.e("SyncEngine", "fullSync($trigger) falhou", e)
|
||||||
status.set { it.copy(phase = SyncPhase.Error, message = e.message ?: "erro interno") }
|
status.set { it.copy(phase = SyncPhase.Error, message = e.message ?: "erro interno") }
|
||||||
@@ -106,6 +109,21 @@ class SyncEngine(
|
|||||||
// ------------------------------------------------------------------ push
|
// ------------------------------------------------------------------ push
|
||||||
|
|
||||||
private suspend fun push(client: ApiClient, author: String): SyncOutcome {
|
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()
|
val queue = db.outbox().queue()
|
||||||
if (queue.isEmpty()) return SyncOutcome.Success(0, 0, 0)
|
if (queue.isEmpty()) return SyncOutcome.Success(0, 0, 0)
|
||||||
var pushed = 0
|
var pushed = 0
|
||||||
@@ -152,6 +170,12 @@ class SyncEngine(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
db.outbox().drop(entry.seq)
|
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++
|
pushed++
|
||||||
}
|
}
|
||||||
is ApiResult.Conflict -> conflicts += resolveConflict(client, note, resp.serverHash)
|
is ApiResult.Conflict -> conflicts += resolveConflict(client, note, resp.serverHash)
|
||||||
@@ -316,23 +340,10 @@ class SyncEngine(
|
|||||||
author = author,
|
author = author,
|
||||||
message = "sync android",
|
message = "sync android",
|
||||||
baseHash = serverHash,
|
baseHash = serverHash,
|
||||||
|
session = this@SyncEngine.session,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun NoteDetailDto.toEntity() = NoteEntity(
|
private fun NoteDetailDto.toEntity() = toNoteEntity()
|
||||||
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 describe(r: ApiResult<*>): String = when (r) {
|
private fun describe(r: ApiResult<*>): String = when (r) {
|
||||||
is ApiResult.Server -> "HTTP ${r.code} ${r.message}"
|
is ApiResult.Server -> "HTTP ${r.code} ${r.message}"
|
||||||
|
|||||||
@@ -22,10 +22,15 @@ class SyncWorker(context: Context, params: WorkerParameters) :
|
|||||||
|
|
||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
val db = NotasDatabase.get(applicationContext)
|
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")) {
|
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.NetworkDown -> Result.retry()
|
||||||
is com.labexposed.notas.domain.SyncOutcome.Failed -> Result.retry()
|
is com.labexposed.notas.domain.SyncOutcome.Failed -> Result.failure()
|
||||||
is com.labexposed.notas.domain.SyncOutcome.Unauthorized -> Result.success()
|
is com.labexposed.notas.domain.SyncOutcome.Unauthorized -> Result.success()
|
||||||
else -> Result.success()
|
else -> Result.success()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ import com.labexposed.notas.domain.SettingsRepository
|
|||||||
import com.labexposed.notas.domain.SyncStatusStore
|
import com.labexposed.notas.domain.SyncStatusStore
|
||||||
import com.labexposed.notas.domain.SyncUiState
|
import com.labexposed.notas.domain.SyncUiState
|
||||||
import com.labexposed.notas.sync.SyncScheduler
|
import com.labexposed.notas.sync.SyncScheduler
|
||||||
|
import android.content.Context
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -22,10 +24,14 @@ import kotlinx.coroutines.flow.combine
|
|||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
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 {
|
sealed class ListEvent {
|
||||||
data class ConnectionOk(val notes: Int) : ListEvent()
|
data class ConnectionOk(val notes: Int) : ListEvent()
|
||||||
data class ConnectionFail(val message: String) : ListEvent()
|
data class ConnectionFail(val message: String) : ListEvent()
|
||||||
|
data class RemoteChange(val title: String) : ListEvent()
|
||||||
}
|
}
|
||||||
|
|
||||||
class NotasViewModel(app: Application) : AndroidViewModel(app) {
|
class NotasViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
@@ -54,8 +60,104 @@ class NotasViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
private val _events = MutableStateFlow<ListEvent?>(null)
|
private val _events = MutableStateFlow<ListEvent?>(null)
|
||||||
val events: StateFlow<ListEvent?> = _events
|
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 {
|
fun saveSettings(s: Settings) = viewModelScope.launch {
|
||||||
settingsRepo.save(s)
|
settingsRepo.save(s)
|
||||||
|
sseApi = if (s.configured) ApiClient(s.serverUrl, s.token) else null
|
||||||
|
onPaused(); onResumed()
|
||||||
SyncScheduler.runNow(getApplication(), "config")
|
SyncScheduler.runNow(getApplication(), "config")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +202,7 @@ class NotasViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
return if (db.notes().byId(mapped) != null) mapped else null
|
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(
|
suspend fun saveNoteSync(
|
||||||
id: String,
|
id: String,
|
||||||
title: String,
|
title: String,
|
||||||
@@ -112,6 +214,8 @@ class NotasViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
val existing = db.notes().byId(targetId) ?: return
|
val existing = db.notes().byId(targetId) ?: return
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val contentChanged = existing.content != content
|
val contentChanged = existing.content != content
|
||||||
|
val metaChanged = existing.title != title || existing.format != format ||
|
||||||
|
existing.categories != categories.joinToString(",")
|
||||||
db.notes().upsert(
|
db.notes().upsert(
|
||||||
existing.copy(
|
existing.copy(
|
||||||
id = targetId,
|
id = targetId,
|
||||||
@@ -124,7 +228,7 @@ class NotasViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
conflictNote = "",
|
conflictNote = "",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (contentChanged) {
|
if (contentChanged || metaChanged) {
|
||||||
// nota em create pendente já está na fila com o conteúdo atual (upsert acima)
|
// 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")) {
|
if (!db.outbox().has(targetId, "create") && !db.outbox().has(targetId, "update")) {
|
||||||
db.outbox().enqueue(OutboxEntity(noteId = targetId, op = "update", queuedAt = now))
|
db.outbox().enqueue(OutboxEntity(noteId = targetId, op = "update", queuedAt = now))
|
||||||
|
|||||||
@@ -2,27 +2,61 @@ package com.labexposed.notas.ui
|
|||||||
|
|
||||||
import com.labexposed.notas.SyncBanner
|
import com.labexposed.notas.SyncBanner
|
||||||
import com.labexposed.notas.domain.Settings
|
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.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
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.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Save
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material3.Button
|
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.Delete
|
||||||
|
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.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.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.MediumTopAppBar
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
import androidx.compose.material3.Scaffold
|
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.Text
|
||||||
import androidx.compose.material3.TextButton
|
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.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -31,83 +65,123 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
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.platform.LocalSoftwareKeyboardController
|
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.delay
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
private val AUTOSAVE_IDLE_MS = 10_000L
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(vm: NotasViewModel, onDone: () -> Unit) {
|
fun SettingsScreen(vm: NotasViewModel, onDone: () -> Unit) {
|
||||||
val settings by vm.settings.collectAsState()
|
val settings by vm.settings.collectAsState()
|
||||||
var url by remember { mutableStateOf(settings.serverUrl) }
|
var url by remember { mutableStateOf(TextFieldValue(settings.serverUrl)) }
|
||||||
var token by remember { mutableStateOf(settings.token) }
|
var token by remember { mutableStateOf(TextFieldValue(settings.token)) }
|
||||||
var author by remember { mutableStateOf(settings.author) }
|
var author by remember { mutableStateOf(TextFieldValue(settings.author)) }
|
||||||
var feedback by remember { mutableStateOf("") }
|
var feedback by remember { mutableStateOf("") }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val events by vm.events.collectAsState()
|
val events by vm.events.collectAsState()
|
||||||
LaunchedEffect(events) {
|
LaunchedEffect(events) {
|
||||||
when (val e = events) {
|
when (val e = events) {
|
||||||
is ListEvent.ConnectionOk -> feedback = "OK! ${e.notes} nota(s) visíveis no servidor."
|
is ListEvent.ConnectionOk -> feedback = "✓ Conectado — ${e.notes} nota(s) no servidor."
|
||||||
is ListEvent.ConnectionFail -> feedback = "Falhou: ${e.message}"
|
is ListEvent.ConnectionFail -> feedback = "✗ ${e.message}"
|
||||||
|
is ListEvent.RemoteChange -> {}
|
||||||
null -> {}
|
null -> {}
|
||||||
}
|
}
|
||||||
if (events != null) vm.consumeEvent()
|
if (events != null) vm.consumeEvent()
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(topBar = { TopAppBar(title = { Text("Servidor") }) }) { pad ->
|
Scaffold(topBar = { MediumTopAppBar(title = { Text("Servidor") }) }) { pad ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(pad).padding(16.dp).verticalScroll(rememberScrollState()),
|
modifier = Modifier.padding(pad).padding(horizontal = 20.dp).verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
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(
|
OutlinedTextField(
|
||||||
value = url, onValueChange = { url = it },
|
value = url, onValueChange = { url = it },
|
||||||
label = { Text("URL da API (ex.: http://192.168.0.10:8080)") },
|
label = { Text("URL da API") },
|
||||||
singleLine = true, modifier = Modifier.fillMaxWidth(),
|
placeholder = { Text("http://192.168.0.10:8080") },
|
||||||
|
singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||||
|
modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(14.dp),
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = token, onValueChange = { token = it },
|
value = token, onValueChange = { token = it },
|
||||||
label = { Text("Token de acesso (NOTES_TOKEN)") },
|
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(
|
OutlinedTextField(
|
||||||
value = author, onValueChange = { author = it },
|
value = author, onValueChange = { author = it },
|
||||||
label = { Text("Autor das revisões") },
|
label = { Text("Autor das revisões") },
|
||||||
singleLine = true, modifier = Modifier.fillMaxWidth(),
|
singleLine = true, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(14.dp),
|
||||||
)
|
)
|
||||||
if (feedback.isNotEmpty()) {
|
if (feedback.isNotEmpty()) {
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
Surface(
|
||||||
Text(feedback, modifier = Modifier.padding(12.dp), style = MaterialTheme.typography.bodyMedium)
|
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)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
Button(onClick = { scope.launch { vm.testConnection(Settings(url, token, author)) } }) {
|
TextButton(onClick = {
|
||||||
Text("Testar conexão")
|
scope.launch { vm.testConnection(Settings(url.text, token.text, author.text)) }
|
||||||
}
|
}) { Text("Testar conexão") }
|
||||||
TextButton(
|
Spacer(Modifier.weight(1f))
|
||||||
enabled = url.isNotBlank() && token.isNotBlank(),
|
androidx.compose.material3.Button(
|
||||||
|
enabled = url.text.isNotBlank() && token.text.isNotBlank() &&
|
||||||
|
(url.text.startsWith("http://") || url.text.startsWith("https://")),
|
||||||
onClick = {
|
onClick = {
|
||||||
vm.saveSettings(Settings(url, token, author))
|
vm.saveSettings(Settings(url.text, token.text, author.text))
|
||||||
onDone()
|
onDone()
|
||||||
},
|
},
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
) { Text("Salvar e continuar") }
|
) { Text("Salvar e continuar") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tela principal: lista local de notas + banner de sincronização. */
|
/* ==================================================================== Lista */
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun NotesListScreen(
|
fun NotesListScreen(
|
||||||
vm: NotasViewModel,
|
vm: NotasViewModel,
|
||||||
@@ -118,163 +192,554 @@ fun NotesListScreen(
|
|||||||
val notes by vm.notes.collectAsState()
|
val notes by vm.notes.collectAsState()
|
||||||
val sync by vm.syncState.collectAsState()
|
val sync by vm.syncState.collectAsState()
|
||||||
val cats by vm.categories.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) }
|
||||||
|
var deleteTarget by remember { mutableStateOf<NoteEntity?>(null) }
|
||||||
|
val snackbar = remember { SnackbarHostState() }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
deleteTarget?.let { target ->
|
||||||
|
androidx.compose.material3.AlertDialog(
|
||||||
|
onDismissRequest = { deleteTarget = null },
|
||||||
|
icon = { Icon(Icons.Default.Delete, null) },
|
||||||
|
title = { Text("Excluir nota?") },
|
||||||
|
text = {
|
||||||
|
Text("“${target.title.ifBlank { "Sem título" }}” será removida deste aparelho " +
|
||||||
|
"e do servidor na próxima sincronização (o histórico dela fica nas revisões " +
|
||||||
|
"do servidor até a exclusão subir). Esta ação não pode ser desfeita pelo app.")
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
vm.deleteNote(target.id)
|
||||||
|
deleteTarget = null
|
||||||
|
scope.launch { snackbar.showSnackbar("Exclusão registrada — sincronizando") }
|
||||||
|
}) { Text("Excluir", color = MaterialTheme.colorScheme.error) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { deleteTarget = null }) { Text("Cancelar") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbar) },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
MediumTopAppBar(
|
||||||
title = { Text("Notas") },
|
title = { Text("Notas") },
|
||||||
actions = {
|
actions = {
|
||||||
TextButton(onClick = { vm.syncNow() }) { Text("Sincronizar") }
|
IconButton(onClick = { vm.syncNow() }) {
|
||||||
TextButton(onClick = onSettings) { Text("Servidor") }
|
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 = {
|
floatingActionButton = {
|
||||||
androidx.compose.material3.FloatingActionButton(onClick = onNew) {
|
ExtendedFloatingActionButton(
|
||||||
Icon(Icons.Default.Add, contentDescription = "Nova nota")
|
onClick = onNew,
|
||||||
}
|
icon = { Icon(Icons.Default.Add, null) },
|
||||||
|
text = { Text("Nova") },
|
||||||
|
)
|
||||||
},
|
},
|
||||||
) { pad ->
|
) { pad ->
|
||||||
Column(modifier = Modifier.padding(pad).fillMaxSize()) {
|
Column(modifier = Modifier.padding(pad).fillMaxSize()) {
|
||||||
SyncBanner(sync)
|
SyncBanner(sync, Modifier.padding(horizontal = 12.dp))
|
||||||
if (notes.isEmpty()) {
|
if (cats.isNotEmpty() || allTags.isNotEmpty()) {
|
||||||
Text(
|
Row(
|
||||||
"Nenhuma nota local.\nToque em + para criar (funciona offline).",
|
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState())
|
||||||
modifier = Modifier.padding(24.dp),
|
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
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(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
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 ->
|
items(shown, key = { it.id }) { note ->
|
||||||
androidx.compose.material3.Card(
|
NoteCard(
|
||||||
|
note = note,
|
||||||
|
catPaths = catPaths,
|
||||||
|
others = presence[note.id] ?: 0,
|
||||||
|
remotelyChanged = remoteChanged.contains(note.id),
|
||||||
onClick = { onOpen(note.id) },
|
onClick = { onOpen(note.id) },
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
onDelete = { deleteTarget = note },
|
||||||
) {
|
)
|
||||||
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,
|
||||||
|
onDelete: () -> 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)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) {
|
||||||
|
Icon(Icons.Default.Delete, "Excluir", Modifier.size(18.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.outline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun EditorScreen(vm: NotasViewModel, noteId: String, onBack: () -> Unit) {
|
fun EditorScreen(vm: NotasViewModel, noteId: String, onBack: () -> Unit) {
|
||||||
var loaded by remember { mutableStateOf(false) }
|
var loaded by remember { mutableStateOf(false) }
|
||||||
var title by remember { mutableStateOf("") }
|
var title by remember { mutableStateOf(TextFieldValue("")) }
|
||||||
var format by remember { mutableStateOf("md") }
|
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 selectedCats by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||||
var savedAt by remember { mutableStateOf("") }
|
var savedAt by remember { mutableStateOf("") }
|
||||||
|
var mode by remember { mutableStateOf(EditorMode.Edit) }
|
||||||
|
var confirmDelete by remember { mutableStateOf(false) }
|
||||||
val cats by vm.categories.collectAsState()
|
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 keyboard = LocalSoftwareKeyboardController.current
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
|
||||||
|
if (confirmDelete) {
|
||||||
|
androidx.compose.material3.AlertDialog(
|
||||||
|
onDismissRequest = { confirmDelete = false },
|
||||||
|
icon = { Icon(Icons.Default.Delete, null) },
|
||||||
|
title = { Text("Excluir nota?") },
|
||||||
|
text = {
|
||||||
|
Text("“${title.text.ifBlank { "Sem título" }}” será removida deste aparelho e do " +
|
||||||
|
"servidor na próxima sincronização. Esta ação não pode ser desfeita pelo app.")
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
confirmDelete = false
|
||||||
|
vm.deleteNote(noteId)
|
||||||
|
onBack()
|
||||||
|
}) { Text("Excluir", color = MaterialTheme.colorScheme.error) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { confirmDelete = false }) { Text("Cancelar") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(noteId) {
|
LaunchedEffect(noteId) {
|
||||||
vm.noteById(noteId)?.let {
|
vm.noteById(noteId)?.let {
|
||||||
title = it.title
|
title = TextFieldValue(it.title)
|
||||||
format = it.format
|
format = it.format
|
||||||
content = it.content
|
body = TextFieldValue(it.content)
|
||||||
selectedCats = it.categories.split(",").filter { c -> c.isNotBlank() }
|
selectedCats = it.categories.split(",").filter { c -> c.isNotBlank() }
|
||||||
}
|
}
|
||||||
loaded = true
|
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() {
|
fun persist() {
|
||||||
if (!loaded) return
|
if (!loaded) return
|
||||||
vm.saveNote(noteId, title, format, selectedCats, content)
|
vm.saveNote(noteId, title.text, format, selectedCats, body.text)
|
||||||
savedAt = java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT)
|
savedAt = java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT).format(java.util.Date())
|
||||||
.format(java.util.Date())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ociosidade: o efeito é reiniciado a cada alteração; só grava se
|
// WYSIWYG: ponte JS avisa mudança → Kotlin busca o markdown (debounce)
|
||||||
// passarem 10 s sem nenhuma (cada tecla cancela o delay anterior)
|
var changeTick by remember { mutableStateOf(0) }
|
||||||
LaunchedEffect(title, format, content, loaded) {
|
LaunchedEffect(controller) {
|
||||||
if (!loaded) return@LaunchedEffect
|
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)
|
delay(AUTOSAVE_IDLE_MS)
|
||||||
persist()
|
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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
androidx.compose.material3.TopAppBar(
|
||||||
title = { Text(title.ifBlank { "Nova nota" }) },
|
navigationIcon = {
|
||||||
navigationIcon = { TextButton(onClick = { persist(); onBack() }) { Text("←") } },
|
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 = {
|
actions = {
|
||||||
Text(savedAt.ifBlank { "—" }, style = MaterialTheme.typography.labelSmall)
|
if ((presence[noteId] ?: 0) > 0) Text("👥", Modifier.padding(end = 6.dp))
|
||||||
TextButton(onClick = { persist(); keyboard?.hide() }) { Text("Salvar") }
|
Text(
|
||||||
|
savedAt.ifBlank { if (loaded) "—" else "" },
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
IconButton(onClick = { persist(); keyboard?.hide() }) {
|
||||||
|
Icon(Icons.Default.CloudDone, "Salvar")
|
||||||
|
}
|
||||||
|
IconButton(onClick = { confirmDelete = true }) {
|
||||||
|
Icon(Icons.Default.Delete, "Excluir", tint = MaterialTheme.colorScheme.outline)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { pad ->
|
) { pad ->
|
||||||
Column(modifier = Modifier.padding(pad).fillMaxSize().padding(12.dp),
|
Column(modifier = Modifier.padding(pad).fillMaxSize()) {
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
if (remoteChanged.contains(noteId)) {
|
||||||
OutlinedTextField(value = title, onValueChange = { title = it },
|
Surface(color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||||
label = { Text("Título") }, singleLine = true, modifier = Modifier.fillMaxWidth())
|
shape = RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp)) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
Row(Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||||
listOf("txt", "md", "html").forEach { f ->
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
FilterChip(selected = format == f, onClick = { format = f }, label = { Text(f.uppercase()) })
|
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()) {
|
// seletor de modo + formato
|
||||||
Text("Categorias", style = MaterialTheme.typography.labelMedium)
|
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(
|
Row(
|
||||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 12.dp, vertical = 2.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
) {
|
) {
|
||||||
cats.forEach { c ->
|
cats.take(14).forEach { c ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = selectedCats.contains(c.id),
|
selected = selectedCats.contains(c.id),
|
||||||
onClick = {
|
onClick = {
|
||||||
selectedCats = if (selectedCats.contains(c.id))
|
selectedCats = if (selectedCats.contains(c.id))
|
||||||
selectedCats - c.id else selectedCats + c.id
|
selectedCats - c.id else selectedCats + c.id
|
||||||
},
|
},
|
||||||
label = { Text(c.name) },
|
label = { Text(c.name, style = MaterialTheme.typography.labelSmall) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
OutlinedTextField(
|
when (mode) {
|
||||||
value = content, onValueChange = { content = it },
|
EditorMode.Edit -> {
|
||||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
OutlinedTextField(
|
||||||
textStyle = androidx.compose.ui.text.TextStyle(fontFamily = FontFamily.Monospace),
|
value = body,
|
||||||
placeholder = { Text("Escreva… #tags viram tags no servidor") },
|
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>
|
||||||
|
"""
|
||||||
Vendored
BIN
Binary file not shown.
Reference in new issue
Block a user