feat: cliente Android offline-first (Kotlin + Jetpack Compose)

- Room (notes/outbox/categories/meta): edição 100% local, fila FIFO de mutações
- SyncEngine bidirecional: push (create/update com base_hash/delete, rekey de id
  local→servidor) + pull (categorias, notas divergentes, exclusões remotas)
- Conflito 409: servidor vence a nota + texto local preservado em cópia
  "Título (conflito)" enfileirada como create — nada perdido, sem relógios
- WorkManager: periódico 1h (rede + backoff exponencial), runNow único REPLACE
  em gatilhos (pós-save, rede restabelecida, boot, config, botão manual)
- OkHttp com timeout/retry em IOException; kotlinx.serialization; DataStore
- UI Compose: tela Servidor (URL+token+teste), Lista com banner de status
  (Offline/Sincronizando/Sincronizado/Erro/401 + pendências + conflitos),
  Editor com autosave por ociosidade (10 s) e categorias multi-seleção
- Build validado: assembleDebug (AGP 8.7.3/Kotlin 2.0.21/SDK 35, JDK 21) —
  app-debug.apk 17,6 MB; gradle-wrapper.jar fora do commit (regenerável com
  `gradle wrapper`, documentado no README)
This commit is contained in:
lgcosta committed 2026-11-04 17:55:50 -03:00
commit 2f63c7a2f5
30 files changed
+2096

No files matched your search

@@ -0,0 +1,98 @@
package com.labexposed.notas
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.labexposed.notas.domain.SyncPhase
import com.labexposed.notas.domain.SyncUiState
import com.labexposed.notas.ui.EditorScreen
import com.labexposed.notas.ui.NotesListScreen
import com.labexposed.notas.ui.NotasViewModel
import com.labexposed.notas.ui.SettingsScreen
import com.labexposed.notas.ui.theme.NotasTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
NotasTheme { NotasApp_() }
}
}
}
@Composable
private fun NotasApp_(vm: NotasViewModel = viewModel()) {
val nav = rememberNavController()
val settings by vm.settings.collectAsState()
NavHost(navController = nav, startDestination = if (settings.configured) "list" else "settings") {
composable("settings") {
SettingsScreen(vm, onDone = { nav.navigate("list") { popUpTo("settings") { inclusive = true } } })
}
composable("list") {
NotesListScreen(
vm = vm,
onOpen = { nav.navigate("editor/$it") },
onNew = { nav.navigate("editor/" + vm.createLocal()) },
onSettings = { nav.navigate("settings") },
)
}
composable("editor/{id}") { entry ->
val id = entry.arguments?.getString("id") ?: return@composable
EditorScreen(vm = vm, noteId = id, onBack = { nav.popBackStack() })
}
}
}
/** Banner de status: Offline · Sincronizando · Sincronizado · Erro · pendências. */
@Composable
fun SyncBanner(state: SyncUiState, modifier: Modifier = Modifier) {
val (bg, fg, label) = when (state.phase) {
SyncPhase.NotConfigured -> Quinteto(Color(0xFF9A9A9A), Color.White, "Configure o servidor ⚙")
SyncPhase.Offline -> Quinteto(Color(0xFF6F6F6F), Color.White, "Offline — ${state.pendingCount} alteração(ões) na fila")
SyncPhase.Syncing -> Quinteto(Color(0xFF3584E4), Color.White, "Sincronizando…")
SyncPhase.Synced -> {
val pend = if (state.pendingCount > 0) " · ${state.pendingCount} pendente(s)" else ""
val conf = if (state.conflicts > 0) " · ⚠ ${state.conflicts} conflito(s)" else ""
val hora = java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT)
.format(java.util.Date(state.lastSyncAt))
Quinteto(Color(0xFF2EC27E), Color(0xFF073042), "Sincronizado às $hora$pend$conf")
}
SyncPhase.Error -> Quinteto(Color(0xFFE01B24), Color.White, "Erro: ${state.message}")
SyncPhase.Unauthorized -> Quinteto(Color(0xFFE01B24), Color.White, "Token inválido — revise em Servidor")
}
Surface(color = bg, shape = RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) {
Row(
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(label, color = fg, style = MaterialTheme.typography.labelLarge)
if (state.dirtyCount > 0 && state.phase == SyncPhase.Synced) {
Text("● ${state.dirtyCount} não enviado(s)", color = fg, style = MaterialTheme.typography.labelMedium)
}
}
}
}
private data class Quinteto(val bg: Color, val fg: Color, val label: String)
@@ -0,0 +1,36 @@
package com.labexposed.notas
import android.app.Application
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import com.labexposed.notas.data.remote.ConnectivityObserver
import com.labexposed.notas.domain.SyncPhase
import com.labexposed.notas.domain.SyncStatusStore
import com.labexposed.notas.sync.SyncScheduler
class NotasApp : Application() {
override fun onCreate() {
super.onCreate()
SyncScheduler.ensurePeriodic(this)
// assim que a rede volta, dispara sync imediato (mutações offline sobem)
try {
val cm = getSystemService(ConnectivityManager::class.java)
cm.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
SyncStatusStore.set { it.copy(phase = if (it.phase == SyncPhase.Offline) SyncPhase.Syncing else it.phase) }
SyncScheduler.runNow(this@NotasApp, "network")
}
})
} catch (_: Exception) {
// alguns OEMs restringem registerDefaultNetworkCallback; o flow da UI cobre
}
// se já houver conexão no boot, tenta logo
if (ConnectivityObserver.isOnline(this)) {
SyncScheduler.runNow(this, "boot")
}
}
}
@@ -0,0 +1,123 @@
package com.labexposed.notas.data.local
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
/**
* Nota espelho local (offline-first). `id` é o id do servidor, ou `L<uuid>`
* para notas criadas offline (renomeadas quando o create for confirmado).
* `serverHash` é o hash SHA-256 do conteúdo conhecido no servidor — a base
* do controle otimista de concorrência (`base_hash` da API).
*/
@Entity(tableName = "notes")
data class NoteEntity(
@PrimaryKey val id: String,
val title: String,
val format: String, // txt | md | html
val categories: String = "", // csv de ids de categoria
val tags: String = "", // csv (derivado pelo servidor)
val content: String,
val serverHash: String = "", // "" = nunca sincronizada
val createdAt: Long, // ms epoch (relógio local)
val updatedAt: Long,
val dirty: Boolean = false, // há edição local não enviada
val pendingDelete: Boolean = false,
val conflictNote: String = "", // mensagem da última resolução de conflito
) {
val isLocalOnly: Boolean get() = id.startsWith("L") && serverHash.isEmpty()
}
/** Fila de mutações pendentes (ordem de chegada = ordem de push). */
@Entity(tableName = "outbox")
data class OutboxEntity(
@PrimaryKey(autoGenerate = true) val seq: Long = 0,
val noteId: String,
val op: String, // create | update | delete
val queuedAt: Long,
)
/** Espelho da árvore de categorias do servidor (lista plana com parent). */
@Entity(tableName = "categories")
data class CategoryEntity(
@PrimaryKey val id: String,
val name: String,
val parent: String,
)
@Entity(tableName = "meta")
data class MetaEntity(
@PrimaryKey val key: String,
val value: String,
)
@Dao
interface NoteDao {
@Query("SELECT * FROM notes ORDER BY updatedAt DESC")
fun observeAll(): Flow<List<NoteEntity>>
@Query("SELECT * FROM notes ORDER BY updatedAt DESC")
suspend fun all(): List<NoteEntity>
@Query("SELECT * FROM notes WHERE id = :id")
suspend fun byId(id: String): NoteEntity?
@Query("SELECT COUNT(*) FROM notes WHERE dirty = 1 OR pendingDelete = 1")
fun observePending(): Flow<Int>
@Upsert
suspend fun upsert(note: NoteEntity)
@Query("DELETE FROM notes WHERE id = :id")
suspend fun delete(id: String)
}
@Dao
interface OutboxDao {
@Query("SELECT * FROM outbox ORDER BY seq ASC")
suspend fun queue(): List<OutboxEntity>
@Query("SELECT COUNT(*) FROM outbox")
fun observeCount(): Flow<Int>
@Query("SELECT EXISTS(SELECT 1 FROM outbox WHERE noteId = :noteId AND op = :op)")
suspend fun has(noteId: String, op: String): Boolean
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun enqueue(entry: OutboxEntity)
@Query("DELETE FROM outbox WHERE seq = :seq")
suspend fun drop(seq: Long)
@Query("DELETE FROM outbox WHERE noteId = :noteId")
suspend fun dropFor(noteId: String)
}
@Dao
interface CategoryDao {
@Query("SELECT * FROM categories")
fun observeAll(): Flow<List<CategoryEntity>>
@Query("DELETE FROM categories")
suspend fun clear()
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(items: List<CategoryEntity>)
@Query("SELECT id FROM categories")
suspend fun ids(): List<String>
}
@Dao
interface MetaDao {
@Query("SELECT value FROM meta WHERE `key` = :key")
suspend fun get(key: String): String?
@Upsert
suspend fun set(entry: MetaEntity)
}
@@ -0,0 +1,32 @@
package com.labexposed.notas.data.local
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(
entities = [NoteEntity::class, OutboxEntity::class, CategoryEntity::class, MetaEntity::class],
version = 1,
exportSchema = false,
)
abstract class NotasDatabase : RoomDatabase() {
abstract fun notes(): NoteDao
abstract fun outbox(): OutboxDao
abstract fun categories(): CategoryDao
abstract fun meta(): MetaDao
companion object {
@Volatile
private var instance: NotasDatabase? = null
fun get(context: Context): NotasDatabase =
instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
NotasDatabase::class.java,
"notas.db",
).build().also { instance = it }
}
}
}
@@ -0,0 +1,89 @@
package com.labexposed.notas.data.remote
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.util.concurrent.TimeUnit
/**
* Cliente HTTP da API notas-online: Bearer token, timeout curto, retry com
* backoff apenas para falhas de rede (IOException), mapeamento de status em
* [ApiResult]. Instanciado por sync (preços de settings podem mudar).
*/
class ApiClient(private val baseUrl: String, private val token: String) {
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private val http = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.writeTimeout(20, TimeUnit.SECONDS)
.retryOnConnectionFailure(false) // retry manual controlado abaixo
.build()
private val jsonMedia = "application/json; charset=utf-8".toMediaType()
private fun url(path: String) = baseUrl.trimEnd('/') + path
/** Executa com até 2 tentativas em erro de rede (backoff 500ms). */
private inline fun <reified T> call(
method: String,
path: String,
body: String? = null,
): ApiResult<T> {
var lastError: IOException? = null
repeat(2) { attempt ->
val request = Request.Builder()
.url(url(path))
.header("Authorization", "Bearer $token")
.method(method, body?.toRequestBody(jsonMedia))
.build()
try {
http.newCall(request).execute().use { resp ->
return when {
resp.isSuccessful -> {
val text = resp.body?.string().orEmpty()
if (T::class == Unit::class) {
ApiResult.Ok(Unit as T)
} else {
ApiResult.Ok(json.decodeFromString<T>(text))
}
}
resp.code == 401 -> ApiResult.Unauthorized
resp.code == 404 -> ApiResult.NotFound
resp.code == 409 -> {
val c = runCatching {
json.decodeFromString<ConflictDto>(resp.body?.string().orEmpty())
}.getOrNull()
ApiResult.Conflict(c?.serverHash ?: "")
}
else -> ApiResult.Server(resp.code, resp.message)
}
}
} catch (e: IOException) {
lastError = e
if (attempt == 0) Thread.sleep(500)
}
}
return ApiResult.Network(lastError?.message ?: "sem conexão")
}
fun testConnection(): ApiResult<List<NoteSummaryDto>> = call("GET", "/api/notes")
fun listNotes(): ApiResult<List<NoteSummaryDto>> = call("GET", "/api/notes")
fun listCategories(): ApiResult<List<CategoryDto>> = call("GET", "/api/categories")
fun getNote(id: String): ApiResult<NoteDetailDto> = call("GET", "/api/notes/$id")
fun createNote(dto: NoteWriteDto): ApiResult<NoteDetailDto> =
call("POST", "/api/notes", json.encodeToString(dto))
fun updateNote(id: String, dto: NoteWriteDto): ApiResult<NoteDetailDto> =
call("PUT", "/api/notes/$id", json.encodeToString(dto))
fun deleteNote(id: String): ApiResult<Unit> = call("DELETE", "/api/notes/$id")
}
@@ -0,0 +1,36 @@
package com.labexposed.notas.data.remote
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
/** Detecção reativa de conectividade (flow) + consulta pontual. */
object ConnectivityObserver {
fun isOnline(context: Context): Boolean {
val cm = context.getSystemService(ConnectivityManager::class.java)
val net = cm.activeNetwork ?: return false
val caps = cm.getNetworkCapabilities(net) ?: return false
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
}
fun observe(context: Context): Flow<Boolean> = callbackFlow {
val cm = context.getSystemService(ConnectivityManager::class.java)
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { trySend(true) }
override fun onLost(network: Network) { trySend(isOnline(context)) }
}
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
cm.registerNetworkCallback(request, callback)
trySend(isOnline(context))
awaitClose { cm.unregisterNetworkCallback(callback) }
}
}
@@ -0,0 +1,71 @@
package com.labexposed.notas.data.remote
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
// ---- payloads do servidor (notas-online API) ----
@Serializable
data class NoteSummaryDto(
val id: String,
val title: String = "",
val format: String = "txt",
val categories: List<String> = emptyList(),
val tags: List<String> = emptyList(),
@SerialName("created_at") val createdAt: String = "",
@SerialName("updated_at") val updatedAt: String = "",
val hash: String = "",
val bytes: Int = 0,
)
@Serializable
data class NoteDetailDto(
val id: String,
val title: String = "",
val format: String = "txt",
val categories: List<String> = emptyList(),
val tags: List<String> = emptyList(),
@SerialName("created_at") val createdAt: String = "",
@SerialName("updated_at") val updatedAt: String = "",
val hash: String = "",
val bytes: Int = 0,
val content: String = "",
)
@Serializable
data class NoteWriteDto(
val title: String,
val format: String,
val categories: List<String> = emptyList(),
val content: String,
val author: String = "",
val message: String = "",
@SerialName("base_hash") val baseHash: String = "",
val session: String = "android",
)
@Serializable
data class CategoryDto(
val id: String,
val name: String,
val parent: String = "",
@SerialName("note_count") val noteCount: Int = 0,
@SerialName("has_child") val hasChild: Boolean = false,
)
@Serializable
data class ConflictDto(
val error: String = "",
@SerialName("server_hash") val serverHash: String = "",
@SerialName("updated_at") val updatedAt: String = "",
)
/** Resultado tipado das chamadas — sem exceções de fluxo de controle. */
sealed class ApiResult<out T> {
data class Ok<T>(val value: T) : ApiResult<T>()
data object Unauthorized : ApiResult<Nothing>()
data object NotFound : ApiResult<Nothing>()
data class Conflict(val serverHash: String) : ApiResult<Nothing>()
data class Network(val message: String) : ApiResult<Nothing>()
data class Server(val code: Int, val message: String) : ApiResult<Nothing>()
}
@@ -0,0 +1,26 @@
package com.labexposed.notas.domain
/** Estado exibido no indicador de sincronização da UI. */
enum class SyncPhase { NotConfigured, Offline, Syncing, Synced, Error, Unauthorized }
data class SyncUiState(
val phase: SyncPhase = SyncPhase.NotConfigured,
val message: String = "",
val lastSyncAt: Long = 0L,
val pendingCount: Int = 0, // mutações na outbox
val dirtyCount: Int = 0, // notas com edição local
val conflicts: Int = 0, // resoluções de conflito na última sincronização
) {
companion object {
val Initial = SyncUiState()
}
}
/** Resultado de uma rodada completa de sincronização. */
sealed class SyncOutcome {
data object NotConfigured : SyncOutcome()
data class Success(val pushed: Int, val pulled: Int, val conflicts: Int) : SyncOutcome()
data class NetworkDown(val message: String) : SyncOutcome()
data object Unauthorized : SyncOutcome()
data class Failed(val message: String) : SyncOutcome()
}
@@ -0,0 +1,49 @@
package com.labexposed.notas.domain
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
data class Settings(
val serverUrl: String = "",
val token: String = "",
val author: String = "android",
) {
val configured: Boolean get() = serverUrl.isNotBlank() && token.isNotBlank()
}
private val Context.dataStore by preferencesDataStore(name = "settings")
/**
* Configuração do cliente (URL do servidor + token Bearer + autor).
* O token fica no sandbox do app (DataStore); para exigência mais forte,
* migrar para Keystore/EncryptedSharedPreferences.
*/
class SettingsRepository(private val context: Context) {
private val keyUrl = stringPreferencesKey("server_url")
private val keyToken = stringPreferencesKey("token")
private val keyAuthor = stringPreferencesKey("author")
val flow: Flow<Settings> = context.dataStore.data.map { p ->
Settings(
serverUrl = p[keyUrl].orEmpty(),
token = p[keyToken].orEmpty(),
author = p[keyAuthor]?.ifBlank { "android" } ?: "android",
)
}
suspend fun current(): Settings = flow.first()
suspend fun save(settings: Settings) {
context.dataStore.edit { p ->
p[keyUrl] = settings.serverUrl.trim().trimEnd('/')
p[keyToken] = settings.token.trim()
p[keyAuthor] = settings.author.trim().ifBlank { "android" }
}
}
}
@@ -0,0 +1,319 @@
package com.labexposed.notas.domain
import com.labexposed.notas.data.local.CategoryEntity
import com.labexposed.notas.data.local.MetaEntity
import com.labexposed.notas.data.local.NoteEntity
import com.labexposed.notas.data.local.NotasDatabase
import com.labexposed.notas.data.local.OutboxEntity
import com.labexposed.notas.data.remote.ApiClient
import com.labexposed.notas.data.remote.ApiResult
import com.labexposed.notas.data.remote.NoteDetailDto
import com.labexposed.notas.data.remote.NoteWriteDto
import java.time.Instant
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Sincronização bidirecional offline-first.
*
* Ordem por rodada:
* 1. PUSH da outbox (ordem FIFO por `seq`): create/update/delete.
* - update usa `base_hash` = hash local conhecido → o servidor devolve
* 409 se outro dispositivo mudou a nota desde então.
* - Conflito (409): política determinística "servidor vence + cópia":
* o texto local nunca enviado é preservado numa NOVA nota
* "Título (conflito)" (entra na fila como create) e a nota principal
* adota o conteúdo do servidor. Nada é perdido — a versão anterior do
* servidor também está no histórico/revisões da plataforma.
* 2. PULL: árvore de categorias + lista de notas; notas locais limpas cujo
* hash divergiu do servidor são rebaixadas (GET completo); notas locais
* ausentes no servidor (e não sujas) são removidas (delete remoto).
*
* Falha de rede em qualquer ponto → NetworkDown (WorkManager aplica backoff).
*/
class SyncEngine(
private val db: NotasDatabase,
private val settings: SettingsRepository,
private val status: SyncStatusStore,
) {
private suspend fun api(): ApiClient? {
val s = settings.current()
if (!s.configured) return null
return ApiClient(s.serverUrl, s.token)
}
suspend fun fullSync(trigger: String = "auto"): SyncOutcome = withContext(Dispatchers.IO) {
val client = api()
if (client == null) {
status.set { it.copy(phase = SyncPhase.NotConfigured) }
return@withContext SyncOutcome.NotConfigured
}
status.set { it.copy(phase = SyncPhase.Syncing) }
val author = settings.current().author
val push = push(client, author)
if (push is SyncOutcome.Unauthorized) {
status.set { it.copy(phase = SyncPhase.Unauthorized, message = "token inválido") }
return@withContext push
}
if (push is SyncOutcome.NetworkDown) {
status.set { it.copy(phase = SyncPhase.Offline, message = push.message) }
return@withContext push
}
val pull = pull(client)
if (pull is SyncOutcome.Unauthorized) {
status.set { it.copy(phase = SyncPhase.Unauthorized, message = "token inválido") }
return@withContext pull
}
if (pull is SyncOutcome.NetworkDown) {
status.set { it.copy(phase = SyncPhase.Offline, message = pull.message) }
return@withContext pull
}
val ok = (push as SyncOutcome.Success) to (pull as SyncOutcome.Success)
val conflicts = ok.first.conflicts + ok.second.conflicts
db.meta().set(MetaEntity("last_pull", System.currentTimeMillis().toString()))
status.set {
it.copy(
phase = SyncPhase.Synced,
message = "sincronizado ($trigger)",
lastSyncAt = System.currentTimeMillis(),
conflicts = conflicts,
)
}
SyncOutcome.Success(ok.first.pushed, ok.second.pulled, conflicts)
}
// ------------------------------------------------------------------ push
private suspend fun push(client: ApiClient, author: String): SyncOutcome {
val queue = db.outbox().queue()
if (queue.isEmpty()) return SyncOutcome.Success(0, 0, 0)
var pushed = 0
var conflicts = 0
for (entry in queue) {
val note = db.notes().byId(entry.noteId)
if (note == null) {
db.outbox().drop(entry.seq)
continue
}
when (entry.op) {
"create" -> {
if (note.pendingDelete) { // criada e apagada offline: nada a enviar
db.outbox().dropFor(note.id)
db.notes().delete(note.id)
continue
}
val resp = client.createNote(note.toWrite(author))
when (resp) {
is ApiResult.Ok -> {
rekeyLocalNote(note, resp.value)
db.outbox().drop(entry.seq)
pushed++
}
is ApiResult.Network -> return SyncOutcome.NetworkDown(resp.message)
is ApiResult.Unauthorized -> return SyncOutcome.Unauthorized
else -> return SyncOutcome.Failed("create ${note.id}: ${describe(resp)}")
}
}
"update" -> {
val sent = note.content
val resp = client.updateNote(note.id, note.toWrite(author))
when (resp) {
is ApiResult.Ok -> {
// limpa a sujeira só se o usuário não digitou durante o push
val fresh = db.notes().byId(note.id) ?: note
val clean = fresh.content == sent
db.notes().upsert(
fresh.copy(
serverHash = resp.value.hash,
dirty = if (clean) false else fresh.dirty,
),
)
db.outbox().drop(entry.seq)
pushed++
}
is ApiResult.Conflict -> conflicts += resolveConflict(client, note, resp.serverHash)
is ApiResult.NotFound -> {
// sumiu no servidor: volta a ser create
db.outbox().drop(entry.seq)
db.notes().upsert(note.copy(serverHash = "", dirty = true))
db.outbox().enqueue(
OutboxEntity(noteId = note.id, op = "create", queuedAt = System.currentTimeMillis()),
)
}
is ApiResult.Network -> return SyncOutcome.NetworkDown(resp.message)
is ApiResult.Unauthorized -> return SyncOutcome.Unauthorized
else -> return SyncOutcome.Failed("update ${note.id}: ${describe(resp)}")
}
}
"delete" -> {
db.outbox().dropFor(note.id) // remove create/update pendentes
if (!note.isLocalOnly) {
when (val resp = client.deleteNote(note.id)) {
is ApiResult.Ok, is ApiResult.NotFound -> Unit
is ApiResult.Network -> return SyncOutcome.NetworkDown(resp.message)
is ApiResult.Unauthorized -> return SyncOutcome.Unauthorized
else -> return SyncOutcome.Failed("delete ${note.id}: ${describe(resp)}")
}
}
db.notes().delete(note.id)
pushed++
}
else -> db.outbox().drop(entry.seq)
}
}
return SyncOutcome.Success(pushed, 0, conflicts)
}
/** 409: servidor vence a nota; texto local vira cópia em conflito. */
private suspend fun resolveConflict(client: ApiClient, note: NoteEntity, serverHash: String): Int {
val detail = client.getNote(note.id)
val server = (detail as? ApiResult.Ok)?.value ?: return 0
if (server.content == note.content) { // convergência: mesmo texto, hash diferente
db.notes().upsert(note.copy(serverHash = server.hash, dirty = false))
db.outbox().dropFor(note.id)
return 0
}
val now = System.currentTimeMillis()
// 1) cópia local com o texto não enviado
val copyId = "L" + UUID.randomUUID().toString().replace("-", "").substring(0, 12)
db.notes().upsert(
note.copy(
id = copyId,
title = note.title + " (conflito)",
serverHash = "",
dirty = true,
pendingDelete = false,
conflictNote = "",
createdAt = now,
updatedAt = now,
),
)
db.outbox().enqueue(OutboxEntity(noteId = copyId, op = "create", queuedAt = now))
// 2) nota principal adota o servidor
db.notes().upsert(
note.copy(
content = server.content,
title = server.title,
format = server.format,
categories = server.categories.joinToString(","),
tags = server.tags.joinToString(","),
serverHash = server.hash,
dirty = false,
conflictNote = "conflito resolvido: seu texto ficou em “${note.title} (conflito)”",
),
)
db.outbox().dropFor(note.id)
return 1
}
private suspend fun rekeyLocalNote(note: NoteEntity, server: NoteDetailDto) {
val now = System.currentTimeMillis()
db.notes().delete(note.id)
db.notes().upsert(
server.toEntity().copy(
createdAt = if (note.createdAt > 0) note.createdAt else now,
dirty = false,
),
)
}
// ------------------------------------------------------------------ pull
private suspend fun pull(client: ApiClient): SyncOutcome {
var pulled = 0
when (val cats = client.listCategories()) {
is ApiResult.Ok -> {
val known = db.categories().ids().toSet()
val fresh = cats.value.map { CategoryEntity(it.id, it.name, it.parent) }
if (fresh.map { it.id }.toSet() != known) {
db.categories().clear()
db.categories().insertAll(fresh)
}
}
is ApiResult.Network -> return SyncOutcome.NetworkDown(cats.message)
is ApiResult.Unauthorized -> return SyncOutcome.Unauthorized
else -> {}
}
val list = client.listNotes()
if (list is ApiResult.Network) return SyncOutcome.NetworkDown(list.message)
if (list is ApiResult.Unauthorized) return SyncOutcome.Unauthorized
if (list !is ApiResult.Ok) return SyncOutcome.Failed("list: ${describe(list)}")
val serverIds = list.value.map { it.id }.toSet()
for (summary in list.value) {
val local = db.notes().byId(summary.id)
when {
local == null -> {
val detail = client.getNote(summary.id)
if (detail is ApiResult.Ok) {
db.notes().upsert(detail.value.toEntity())
pulled++
}
}
local.dirty || local.pendingDelete -> { /* push é dono */ }
local.serverHash != summary.hash -> {
val detail = client.getNote(summary.id)
if (detail is ApiResult.Ok) {
db.notes().upsert(detail.value.toEntity().copy(createdAt = local.createdAt))
pulled++
}
}
}
}
// exclusões remotas: notas limpas que sumiram do servidor
for (local in db.notes().all()) {
if (!serverIds.contains(local.id) && !local.dirty && !local.pendingDelete) {
db.notes().delete(local.id)
db.outbox().dropFor(local.id)
}
}
return SyncOutcome.Success(0, pulled, 0)
}
// ----------------------------------------------------------------- utils
private fun NoteEntity.toWrite(author: String) = NoteWriteDto(
title = title.ifBlank { "Sem título" },
format = format,
categories = categories.split(",").filter { it.isNotBlank() },
content = content,
author = author,
message = "sync android",
baseHash = serverHash,
)
private fun NoteDetailDto.toEntity() = NoteEntity(
id = id,
title = title,
format = format,
categories = categories.joinToString(","),
tags = tags.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) {
is ApiResult.Server -> "HTTP ${r.code} ${r.message}"
is ApiResult.Network -> r.message
is ApiResult.Conflict -> "conflito"
else -> r.toString()
}
}
@@ -0,0 +1,16 @@
package com.labexposed.notas.domain
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/** Fonte único do estado de sincronização observado pela UI e pelos workers. */
object SyncStatusStore {
private val _state = MutableStateFlow(SyncUiState.Initial)
val state: StateFlow<SyncUiState> = _state.asStateFlow()
fun set(transform: (SyncUiState) -> SyncUiState) {
_state.value = transform(_state.value)
}
}
@@ -0,0 +1,78 @@
package com.labexposed.notas.sync
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.Constraints
import androidx.work.BackoffPolicy
import com.labexposed.notas.data.local.NotasDatabase
import com.labexposed.notas.domain.SettingsRepository
import com.labexposed.notas.domain.SyncEngine
import com.labexposed.notas.domain.SyncStatusStore
import java.util.concurrent.TimeUnit
class SyncWorker(context: Context, params: WorkerParameters) :
CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val db = NotasDatabase.get(applicationContext)
val engine = SyncEngine(db, SettingsRepository(applicationContext), SyncStatusStore)
return when (val outcome = engine.fullSync(trigger = inputData.getString(TRIGGER) ?: "auto")) {
is com.labexposed.notas.domain.SyncOutcome.NetworkDown -> Result.retry()
is com.labexposed.notas.domain.SyncOutcome.Failed -> Result.retry()
is com.labexposed.notas.domain.SyncOutcome.Unauthorized -> Result.success()
else -> Result.success()
}
}
companion object {
const val TRIGGER = "trigger"
internal const val UNIQUE_PERIODIC = "notas-sync-periodic"
internal const val UNIQUE_ONE = "notas-sync-one"
}
}
object SyncScheduler {
private val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
/** Ciclo periódico: a cada 1h (com janela de flexibilidade), apenas com rede. */
fun ensurePeriodic(context: Context) {
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
SyncWorker.UNIQUE_PERIODIC,
ExistingPeriodicWorkPolicy.KEEP,
PeriodicWorkRequestBuilder<SyncWorker>(1, TimeUnit.HOURS)
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build(),
)
}
/**
* "Sincronizar agora" / gatilho pós-save / rede restabelecida:
* REPLACE garante exatamente um run pendente, sem acumular fila.
*/
fun runNow(context: Context, trigger: String = "manual") {
WorkManager.getInstance(context).enqueueUniqueWork(
SyncWorker.UNIQUE_ONE,
ExistingWorkPolicy.REPLACE,
OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(constraints)
.setInputData(androidx.work.Data.Builder().putString(SyncWorker.TRIGGER, trigger).build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS)
.build(),
)
}
fun cancelAll(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(SyncWorker.UNIQUE_ONE)
}
}
@@ -0,0 +1,136 @@
package com.labexposed.notas.ui
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.labexposed.notas.data.local.NotasDatabase
import com.labexposed.notas.data.local.NoteEntity
import com.labexposed.notas.data.local.OutboxEntity
import com.labexposed.notas.data.remote.ApiClient
import com.labexposed.notas.data.remote.ApiResult
import com.labexposed.notas.domain.Settings
import com.labexposed.notas.domain.SettingsRepository
import com.labexposed.notas.domain.SyncStatusStore
import com.labexposed.notas.domain.SyncUiState
import com.labexposed.notas.sync.SyncScheduler
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
sealed class ListEvent {
data class ConnectionOk(val notes: Int) : ListEvent()
data class ConnectionFail(val message: String) : ListEvent()
}
class NotasViewModel(app: Application) : AndroidViewModel(app) {
private val db = NotasDatabase.get(app)
private val settingsRepo = SettingsRepository(app)
val settings: StateFlow<Settings> = settingsRepo.flow
.stateIn(viewModelScope, SharingStarted.Eagerly, Settings())
val notes: StateFlow<List<NoteEntity>> = db.notes().observeAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val categories: StateFlow<List<com.labexposed.notas.data.local.CategoryEntity>> =
db.categories().observeAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val syncState: StateFlow<SyncUiState> = combine(
SyncStatusStore.state,
db.outbox().observeCount(),
db.notes().observePending(),
) { state, outbox, dirty ->
state.copy(pendingCount = outbox, dirtyCount = dirty)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), SyncUiState.Initial)
private val _events = MutableStateFlow<ListEvent?>(null)
val events: StateFlow<ListEvent?> = _events
fun saveSettings(s: Settings) = viewModelScope.launch {
settingsRepo.save(s)
SyncScheduler.runNow(getApplication(), "config")
}
fun testConnection(s: Settings) = viewModelScope.launch(Dispatchers.IO) {
val result = ApiClient(s.serverUrl, s.token).testConnection()
_events.value = when (result) {
is ApiResult.Ok -> ListEvent.ConnectionOk(result.value.size)
is ApiResult.Unauthorized -> ListEvent.ConnectionFail("token inválido (401)")
is ApiResult.Network -> ListEvent.ConnectionFail("sem conexão: ${result.message}")
else -> ListEvent.ConnectionFail(result.toString())
}
}
fun consumeEvent() { _events.value = null }
fun syncNow() {
SyncScheduler.runNow(getApplication(), "manual")
}
fun createLocal(): String {
val id = "L" + UUID.randomUUID().toString().replace("-", "").substring(0, 12)
viewModelScope.launch(Dispatchers.IO) {
val now = System.currentTimeMillis()
db.notes().upsert(
NoteEntity(
id = id, title = "", format = "md", content = "",
createdAt = now, updatedAt = now, dirty = true,
),
)
db.outbox().enqueue(OutboxEntity(noteId = id, op = "create", queuedAt = now))
}
return id
}
/** Salva edição local + enfileira mutação + agenda sync (debounce no editor). */
fun saveNote(
id: String,
title: String,
format: String,
categories: List<String>,
content: String,
) = viewModelScope.launch(Dispatchers.IO) {
val existing = db.notes().byId(id) ?: return@launch
val now = System.currentTimeMillis()
val contentChanged = existing.content != content
db.notes().upsert(
existing.copy(
title = title,
format = format,
categories = categories.joinToString(","),
content = content,
updatedAt = now,
dirty = true,
conflictNote = "",
),
)
if (contentChanged && !db.outbox().has(id, "create")) {
db.outbox().enqueue(OutboxEntity(noteId = id, op = "update", queuedAt = now))
SyncScheduler.runNow(getApplication(), "autosave")
}
}
fun deleteNote(id: String) = viewModelScope.launch(Dispatchers.IO) {
val note = db.notes().byId(id) ?: return@launch
if (note.isLocalOnly) {
db.notes().delete(id)
db.outbox().dropFor(id)
} else {
db.notes().upsert(note.copy(pendingDelete = true, dirty = true, updatedAt = System.currentTimeMillis()))
db.outbox().enqueue(OutboxEntity(noteId = id, op = "delete", queuedAt = System.currentTimeMillis()))
SyncScheduler.runNow(getApplication(), "delete")
}
}
suspend fun noteById(id: String): NoteEntity? = withContext(Dispatchers.IO) {
db.notes().byId(id)
}
}
@@ -0,0 +1,280 @@
package com.labexposed.notas.ui
import com.labexposed.notas.SyncBanner
import com.labexposed.notas.domain.Settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.filled.Add
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private val AUTOSAVE_IDLE_MS = 10_000L
/** Tela de configuração: URL do servidor + token + autor, com teste de conexão. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(vm: NotasViewModel, onDone: () -> Unit) {
val settings by vm.settings.collectAsState()
var url by remember { mutableStateOf(settings.serverUrl) }
var token by remember { mutableStateOf(settings.token) }
var author by remember { mutableStateOf(settings.author) }
var feedback by remember { mutableStateOf("") }
val scope = rememberCoroutineScope()
val events by vm.events.collectAsState()
LaunchedEffect(events) {
when (val e = events) {
is ListEvent.ConnectionOk -> feedback = "OK! ${e.notes} nota(s) visíveis no servidor."
is ListEvent.ConnectionFail -> feedback = "Falhou: ${e.message}"
null -> {}
}
if (events != null) vm.consumeEvent()
}
Scaffold(topBar = { TopAppBar(title = { Text("Servidor") }) }) { pad ->
Column(
modifier = Modifier.padding(pad).padding(16.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = url, onValueChange = { url = it },
label = { Text("URL da API (ex.: http://192.168.0.10:8080)") },
singleLine = true, modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = token, onValueChange = { token = it },
label = { Text("Token de acesso (NOTES_TOKEN)") },
singleLine = true, modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = author, onValueChange = { author = it },
label = { Text("Autor das revisões") },
singleLine = true, modifier = Modifier.fillMaxWidth(),
)
if (feedback.isNotEmpty()) {
Card(modifier = Modifier.fillMaxWidth()) {
Text(feedback, modifier = Modifier.padding(12.dp), style = MaterialTheme.typography.bodyMedium)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { scope.launch { vm.testConnection(Settings(url, token, author)) } }) {
Text("Testar conexão")
}
TextButton(
enabled = url.isNotBlank() && token.isNotBlank(),
onClick = {
vm.saveSettings(Settings(url, token, author))
onDone()
},
) { Text("Salvar e continuar") }
}
}
}
}
/** Tela principal: lista local de notas + banner de sincronização. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NotesListScreen(
vm: NotasViewModel,
onOpen: (String) -> Unit,
onNew: () -> Unit,
onSettings: () -> Unit,
) {
val notes by vm.notes.collectAsState()
val sync by vm.syncState.collectAsState()
val cats by vm.categories.collectAsState()
val catNames = remember(cats) { cats.associate { it.id to it.name } }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Notas") },
actions = {
TextButton(onClick = { vm.syncNow() }) { Text("Sincronizar") }
TextButton(onClick = onSettings) { Text("Servidor") }
},
)
},
floatingActionButton = {
androidx.compose.material3.FloatingActionButton(onClick = onNew) {
Icon(Icons.Default.Add, contentDescription = "Nova nota")
}
},
) { pad ->
Column(modifier = Modifier.padding(pad).fillMaxSize()) {
SyncBanner(sync)
if (notes.isEmpty()) {
Text(
"Nenhuma nota local.\nToque em + para criar (funciona offline).",
modifier = Modifier.padding(24.dp),
style = MaterialTheme.typography.bodyMedium,
)
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(8.dp),
) {
items(notes, key = { it.id }) { note ->
androidx.compose.material3.Card(
onClick = { onOpen(note.id) },
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
Row(
modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(Modifier.weight(1f)) {
Text(
(note.title.ifBlank { "Sem título" }) +
when (note.format) { "md" -> ".md"; "html" -> ".html"; else -> ".txt" },
style = MaterialTheme.typography.titleSmall,
)
val bits = mutableListOf(
java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT)
.format(java.util.Date(note.updatedAt)),
)
note.categories.split(",").filter { it.isNotBlank() }
.map { catNames[it] ?: it }.ifNotEmpty()?.let { bits.add(it.joinToString(" · ")) }
if (note.tags.isNotBlank()) bits.add(note.tags.split(",").joinToString(" ") { "#$it" })
Text(bits.joinToString(" · "), style = MaterialTheme.typography.bodySmall)
if (note.conflictNote.isNotBlank()) {
Text("⚠ ${note.conflictNote}", style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error)
}
}
Column(horizontalAlignment = androidx.compose.ui.Alignment.End) {
if (note.dirty) Text("● não enviado", color = MaterialTheme.colorScheme.tertiary,
style = MaterialTheme.typography.labelSmall)
if (note.pendingDelete) Text("🗑 fila", style = MaterialTheme.typography.labelSmall)
}
}
}
}
}
}
}
}
private fun <T> List<T>.ifNotEmpty(): List<T>? = if (isEmpty()) null else this
/** Editor offline-first: autosave local após 10 s de ociosidade + salvar explícito. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditorScreen(vm: NotasViewModel, noteId: String, onBack: () -> Unit) {
var loaded by remember { mutableStateOf(false) }
var title by remember { mutableStateOf("") }
var format by remember { mutableStateOf("md") }
var content by remember { mutableStateOf("") }
var selectedCats by remember { mutableStateOf<List<String>>(emptyList()) }
var savedAt by remember { mutableStateOf("") }
val cats by vm.categories.collectAsState()
val keyboard = LocalSoftwareKeyboardController.current
LaunchedEffect(noteId) {
vm.noteById(noteId)?.let {
title = it.title
format = it.format
content = it.content
selectedCats = it.categories.split(",").filter { c -> c.isNotBlank() }
}
loaded = true
}
fun persist() {
if (!loaded) return
vm.saveNote(noteId, title, format, selectedCats, content)
savedAt = java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT)
.format(java.util.Date())
}
// ociosidade: o efeito é reiniciado a cada alteração; só grava se
// passarem 10 s sem nenhuma (cada tecla cancela o delay anterior)
LaunchedEffect(title, format, content, loaded) {
if (!loaded) return@LaunchedEffect
delay(AUTOSAVE_IDLE_MS)
persist()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(title.ifBlank { "Nova nota" }) },
navigationIcon = { TextButton(onClick = { persist(); onBack() }) { Text("←") } },
actions = {
Text(savedAt.ifBlank { "—" }, style = MaterialTheme.typography.labelSmall)
TextButton(onClick = { persist(); keyboard?.hide() }) { Text("Salvar") }
},
)
},
) { pad ->
Column(modifier = Modifier.padding(pad).fillMaxSize().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(value = title, onValueChange = { title = it },
label = { Text("Título") }, singleLine = true, modifier = Modifier.fillMaxWidth())
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("txt", "md", "html").forEach { f ->
FilterChip(selected = format == f, onClick = { format = f }, label = { Text(f.uppercase()) })
}
}
if (cats.isNotEmpty()) {
Text("Categorias", style = MaterialTheme.typography.labelMedium)
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
cats.forEach { c ->
FilterChip(
selected = selectedCats.contains(c.id),
onClick = {
selectedCats = if (selectedCats.contains(c.id))
selectedCats - c.id else selectedCats + c.id
},
label = { Text(c.name) },
)
}
}
}
OutlinedTextField(
value = content, onValueChange = { content = it },
modifier = Modifier.fillMaxWidth().weight(1f),
textStyle = androidx.compose.ui.text.TextStyle(fontFamily = FontFamily.Monospace),
placeholder = { Text("Escreva… #tags viram tags no servidor") },
)
}
}
}
@@ -0,0 +1,38 @@
package com.labexposed.notas.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
// seed Greybird/Mousepad-like
private val Light = lightColorScheme(
primary = Color(0xFF1C71D8),
secondary = Color(0xFF3584E4),
background = Color(0xFFF6F5F4),
surface = Color(0xFFFFFFFF),
)
private val Dark = darkColorScheme(
primary = Color(0xFF78AEFF),
secondary = Color(0xFF3584E4),
)
@Composable
fun NotasTheme(content: @Composable () -> Unit) {
val ctx = LocalContext.current
val dynamic = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
val scheme = when {
dynamic && isSystemInDarkTheme() -> dynamicDarkColorScheme(ctx)
dynamic -> dynamicLightColorScheme(ctx)
isSystemInDarkTheme() -> Dark
else -> Light
}
MaterialTheme(colorScheme = scheme, content = content)
}