6 Commits
Author SHA1 Message Date
lgcosta a8c8a5a998 feat(ui): modernização visual Material 3 (v0.3.0)
- Edge-to-edge (enableEdgeToEdge) com Scaffold/insets em todas as telas
- Compose BOM 2024.12 -> 2025.06; tipografia e tema com dynamic color
- SyncBanner colorido removido -> SyncStatusRow compacto com chips M3
  acionáveis (sync agora / abrir config / erro em errorContainer)
- Lista: busca por título/conteúdo/tag (filterNotes testado),
  pull-to-refresh, animateItem, contentType, estado vazio com CTA
- Ícones no lugar de emoji (pasta, presença, exclusão, aviso)
- Editor: chip de presença acessível; imports limpos
- Teste unitário de busca (NoteSearchTest)
- versionCode 5 / versionName 0.3.0
2026-11-05 21:48:16 -03:00
codeall 8561ae2f8b fix: robustez de sync e cancelamento (v0.2.2)
- SyncEngine: CancellationException volta a propagar (não vira erro falso)
- SyncWorker: Failed (4xx/5xx) agora é failure, só NetworkDown retenta
- ApiClient: métodos suspend com delay() em vez de Thread.sleep
- SseClient: cancelamento fecha o socket; parse tolera 'data:' sem espaço
- Room v3: migração real 2->3 (índice único outbox) sem apagar a fila
- versionCode 4 / versionName 0.2.2
2026-11-05 21:02:10 -03:00
lgcosta 34a6c38540 chore(wrapper): Added wrapper jar and enabled gradlew execution
- Added gradle-wrapper.jar to enable version-pinned Gradle bootstrapping.
- Set executable permission on gradlew for direct wrapper invocation.
2026-11-05 20:48:17 -03:00
lgcosta 57edfbe4df feat: exclusão de notas na UI (lista e editor) com confirmação e sync
- Botão 🗑 no card da lista e na barra do editor; AlertDialog de confirmação
  explicando o efeito (remoto sobe na sync; local-only some direto)
- Exclusões já eram enfileiradas (pendingDelete + op delete) e o engine já
  tratava 404; propagação via SSE 'deleted' para outras sessões
- versionCode 3 / versionName 0.2.1; README atualizado
- Testado no emulador: delete pela lista → servidor 0 notas; delete pelo
  editor → idem, com volta à lista; pull de nota da Web e exclusão seguinte ok
2026-11-05 07:48:57 -03:00
lgcosta 9508dbf24a chore: ignorar .kotlin/ (cache do plugin Kotlin) 2026-11-04 20:57:56 -03:00
lgcosta 400ab066b2 chore: versionCode 2 / versionName 0.2.0 (release v0.2.0) 2026-11-04 20:56:08 -03:00
15 changed files with 394 additions and 138 deletions

No files matched your search

+1
View File
@@ -1,5 +1,6 @@
*.iml *.iml
.gradle/ .gradle/
.kotlin/
build/ build/
local.properties local.properties
.idea/ .idea/
+10 -12
View File
@@ -106,17 +106,12 @@ notas-online-android/
tags/atributos (scripts/`on*` sanitizados). Preview é o mesmo WebView não-editável. Barra de 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). ferramentas aplica `execCommand` (B I S H1 H2 listas ❝ 🔗 código).
## UI (Material 3, dynamic color) ## UI (Material 3, dynamic color, edge-to-edge) — v0.3.0
- **Lista**: `MediumTopAppBar`, banner de status colorido (Offline/Sincronizando/Sincronizado± - **Lista**: `MediumTopAppBar`, estado de sync compacto com cores M3 e chips acionáveis, busca por título/conteúdo/tags, atualização por gesto, filtros por categoria/tag, cards animados com status e presença acessível, estado vazio com CTA e FAB "Nova".
pendências/Erro), chips de filtro (Todas / 📁 categoria com caminho da árvore / #tag), cards - **Editor**: campo de título na top bar, seletor de modo (Editar / WYSIWYG / Preview), chips de formato e categorias, corpo monoespaçado com toolbar Markdown, presença com ícone acessível, autosave e exclusão confirmada.
com título+extensão, tempo relativo, tags, chip de categoria(s) e ícone de status por nota - **Visual**: cores dinâmicas quando disponíveis, tipografia ajustada e edge-to-edge para integrar conteúdo às barras do sistema.
(☁✓ sincronizada, ● âmbar não enviada, 🗑 exclusão na fila, ⚠ conflito/mudança remota, 👥N - **Exclusões sincronizam**: apagar marca `pendingDelete`, entra `delete` na fila e sobe na próxima sync; nota local-only some direto. Eventos SSE removem notas limpas das outras sessões.
presença), FAB estendido "Nova".
- **Editor**: campo de título na top bar, seletor de modo (Editar / WYSIWYG / Preview), chips de
formato (TXT/MD/HTML) e de categorias (multi-seleção), corpo monoespaçado com toolbar de
Markdown (B I S H1 H2 • ❝ 🔗 ` { }) que envolve/prefixa a seleção, selo de presença 👥, e
horário do último salvamento.
- **Servidor**: URL + token + autor com *Testar conexão* e validação de esquema http(s). - **Servidor**: URL + token + autor com *Testar conexão* e validação de esquema http(s).
## Compilar e instalar ## Compilar e instalar
@@ -135,8 +130,11 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk
## Testado em ## Testado em
- **Build**: `assembleRelease` limpo (JDK 21 / AGP 8.7.3 / Kotlin 2.0.21 / SDK 35), APK assinado - **Build**: `testDebugUnitTest` + `assembleDebug` + `assembleRelease` limpos (JDK 21 / AGP 8.7.3 /
com R8 ≈ **1,6 MB** (debug ≈ 17 MB). Kotlin 2.0.21 / SDK 35 / Compose BOM 2025.06); release R8 ≈ **1,6 MB**, debug ≈ 17 MB.
- **Emulador Android 15 (x86_64, Pixel 6)** — v0.3.0: edge-to-edge, status de sync compacto,
campo de busca, estado vazio com CTA e editor com chips de modo/formato verificados por
screenshot + uiautomator (config → lista → criar nota → editor).
- **Emulador Android 15 (x86_64, Pixel 6)** contra o servidor notas-online real, cenários: - **Emulador Android 15 (x86_64, Pixel 6)** contra o servidor notas-online real, cenários:
- configuração de servidor com *Testar conexão* → OK; - configuração de servidor com *Testar conexão* → OK;
- criar nota offline → autosave por ociosidade (10 s) → `POST` único (sem duplicatas); - criar nota offline → autosave por ociosidade (10 s) → `POST` único (sem duplicatas);
+3 -4
View File
@@ -13,9 +13,8 @@ android {
defaultConfig { defaultConfig {
applicationId = "com.labexposed.notas" applicationId = "com.labexposed.notas"
minSdk = 26 minSdk = 26
targetSdk = 35 versionCode = 5
versionCode = 1 versionName = "0.3.0"
versionName = "1.0.0"
} }
buildTypes { buildTypes {
@@ -42,7 +41,7 @@ android {
} }
dependencies { dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.12.01") val composeBom = platform("androidx.compose:compose-bom:2025.06.00")
implementation(composeBom) implementation(composeBom)
// UI // UI
@@ -3,30 +3,16 @@ package com.labexposed.notas
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement import androidx.activity.enableEdgeToEdge
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.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.compose.LifecycleEventEffect 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 androidx.lifecycle.Lifecycle
import com.labexposed.notas.domain.SyncPhase
import com.labexposed.notas.domain.SyncUiState
import com.labexposed.notas.ui.EditorScreen import com.labexposed.notas.ui.EditorScreen
import com.labexposed.notas.ui.NotesListScreen import com.labexposed.notas.ui.NotesListScreen
import com.labexposed.notas.ui.NotasViewModel import com.labexposed.notas.ui.NotasViewModel
@@ -36,6 +22,7 @@ import com.labexposed.notas.ui.theme.NotasTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent { setContent {
NotasTheme { NotasApp_() } NotasTheme { NotasApp_() }
} }
@@ -68,36 +55,3 @@ private fun NotasApp_(vm: NotasViewModel = viewModel()) {
} }
} }
} }
/** 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)
@@ -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,23 +74,23 @@ 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")
fun renderMd(content: String): ApiResult<String> { suspend fun renderMd(content: String): ApiResult<String> {
val body = """{"content":${json.encodeToString(content)},"format":"md"}""" val body = """{"content":${json.encodeToString(content)},"format":"md"}"""
val r = call<RenderDto>("POST", "/api/render", body) 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> return if (r is ApiResult.Ok) ApiResult.Ok((r.value as RenderDto).html) else r as ApiResult<Nothing>
@@ -1,10 +1,14 @@
package com.labexposed.notas.data.remote package com.labexposed.notas.data.remote
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
@@ -29,6 +33,8 @@ class SseClient(private val baseUrl: String, private val token: String, private
try { try {
streamOnce(onEvent) streamOnce(onEvent)
backoff = 1_000L // stream terminou limpo (scope cancelado não chega aqui) backoff = 1_000L // stream terminou limpo (scope cancelado não chega aqui)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) { } catch (_: Exception) {
// rede caiu / servidor fora: espera e tenta de novo // rede caiu / servidor fora: espera e tenta de novo
} }
@@ -37,20 +43,24 @@ class SseClient(private val baseUrl: String, private val token: String, private
} }
} }
private fun streamOnce(onEvent: (SseEvent) -> Unit) { private suspend fun streamOnce(onEvent: (SseEvent) -> Unit) {
val request = Request.Builder() val request = Request.Builder()
.url(baseUrl.trimEnd('/') + "/api/events?session=" + sessionId) .url(baseUrl.trimEnd('/') + "/api/events?session=" + sessionId)
.header("Authorization", "Bearer $token") .header("Authorization", "Bearer $token")
.header("Accept", "text/event-stream") .header("Accept", "text/event-stream")
.build() .build()
http.newCall(request).execute().use { resp -> 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}") if (!resp.isSuccessful) throw java.io.IOException("SSE HTTP ${resp.code}")
val reader = resp.body?.source()?.inputStream()?.bufferedReader() ?: throw java.io.IOException("sem corpo") val reader = resp.body?.source()?.inputStream()?.bufferedReader() ?: throw java.io.IOException("sem corpo")
var data = StringBuilder() var data = StringBuilder()
while (true) { while (true) {
currentCoroutineContext().ensureActive()
val line = reader.readLine() ?: break val line = reader.readLine() ?: break
when { when {
line.startsWith("data: ") -> data.append(line.removePrefix("data: ")) line.startsWith("data: ") -> data.append(line.removePrefix("data: "))
line.startsWith("data:") -> data.append(line.removePrefix("data:").trimStart())
line.isEmpty() && data.isNotEmpty() -> { line.isEmpty() && data.isNotEmpty() -> {
val raw = data.toString() val raw = data.toString()
data = StringBuilder() data = StringBuilder()
@@ -51,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") }
@@ -30,7 +30,7 @@ class SyncWorker(context: Context, params: WorkerParameters) :
) )
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()
} }
@@ -0,0 +1,20 @@
package com.labexposed.notas.ui
import com.labexposed.notas.data.local.NoteEntity
internal fun filterNotes(
notes: List<NoteEntity>,
query: String,
categoryId: String? = null,
tag: String? = null,
): List<NoteEntity> {
val normalizedQuery = query.trim()
return notes.filter { note ->
(categoryId == null || note.categories.split(",").contains(categoryId)) &&
(tag == null || note.tags.split(",").contains(tag)) &&
(normalizedQuery.isEmpty() ||
note.title.contains(normalizedQuery, ignoreCase = true) ||
note.content.contains(normalizedQuery, ignoreCase = true) ||
note.tags.split(",").any { it.contains(normalizedQuery, ignoreCase = true) })
}
}
@@ -1,8 +1,7 @@
package com.labexposed.notas.ui package com.labexposed.notas.ui
import com.labexposed.notas.SyncBanner import com.labexposed.notas.domain.Settings as ServerSettings
import com.labexposed.notas.domain.Settings import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.horizontalScroll 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.Box
@@ -30,16 +29,23 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CloudDone import androidx.compose.material.icons.filled.CloudDone
import androidx.compose.material.icons.filled.CloudOff import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material.icons.filled.CloudSync import androidx.compose.material.icons.filled.CloudSync
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Group
import androidx.compose.material.icons.automirrored.filled.NoteAdd
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedAssistChip
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton 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
@@ -51,6 +57,7 @@ import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.Surface 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
@@ -161,14 +168,14 @@ fun SettingsScreen(vm: NotasViewModel, onDone: () -> Unit) {
} }
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
TextButton(onClick = { TextButton(onClick = {
scope.launch { vm.testConnection(Settings(url.text, token.text, author.text)) } scope.launch { vm.testConnection(ServerSettings(url.text, token.text, author.text)) }
}) { Text("Testar conexão") } }) { Text("Testar conexão") }
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
androidx.compose.material3.Button( androidx.compose.material3.Button(
enabled = url.text.isNotBlank() && token.text.isNotBlank() && enabled = url.text.isNotBlank() && token.text.isNotBlank() &&
(url.text.startsWith("http://") || url.text.startsWith("https://")), (url.text.startsWith("http://") || url.text.startsWith("https://")),
onClick = { onClick = {
vm.saveSettings(Settings(url.text, token.text, author.text)) vm.saveSettings(ServerSettings(url.text, token.text, author.text))
onDone() onDone()
}, },
shape = RoundedCornerShape(14.dp), shape = RoundedCornerShape(14.dp),
@@ -195,7 +202,32 @@ fun NotesListScreen(
val remoteChanged by vm.remoteChanged.collectAsState() val remoteChanged by vm.remoteChanged.collectAsState()
var filterCat by remember { mutableStateOf<String?>(null) } var filterCat by remember { mutableStateOf<String?>(null) }
var filterTag by remember { mutableStateOf<String?>(null) } var filterTag by remember { mutableStateOf<String?>(null) }
var query by remember { mutableStateOf("") }
var deleteTarget by remember { mutableStateOf<NoteEntity?>(null) }
val snackbar = remember { SnackbarHostState() } 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 catPaths = remember(cats) {
val byId = cats.associateBy { it.id } val byId = cats.associateBy { it.id }
@@ -214,13 +246,9 @@ fun NotesListScreen(
val allTags = remember(notes) { val allTags = remember(notes) {
notes.flatMap { it.tags.split(",").filter { t -> t.isNotBlank() } }.distinct().sorted() notes.flatMap { it.tags.split(",").filter { t -> t.isNotBlank() } }.distinct().sorted()
} }
val shown = remember(notes, filterCat, filterTag) { val shown = remember(notes, filterCat, filterTag, query) {
notes.filter { n -> filterNotes(notes, query, filterCat, filterTag)
(filterCat == null || n.categories.split(",").contains(filterCat)) &&
(filterTag == null || n.tags.split(",").contains(filterTag))
}
} }
val events by vm.events.collectAsState() val events by vm.events.collectAsState()
LaunchedEffect(events) { LaunchedEffect(events) {
if (events is ListEvent.RemoteChange) { if (events is ListEvent.RemoteChange) {
@@ -228,7 +256,6 @@ fun NotesListScreen(
vm.consumeEvent() vm.consumeEvent()
} }
} }
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(snackbar) }, snackbarHost = { SnackbarHost(snackbar) },
topBar = { topBar = {
@@ -259,48 +286,84 @@ fun NotesListScreen(
) )
}, },
) { pad -> ) { pad ->
Column(modifier = Modifier.padding(pad).fillMaxSize()) { PullToRefreshBox(
SyncBanner(sync, Modifier.padding(horizontal = 12.dp)) isRefreshing = sync.phase == SyncPhase.Syncing,
if (cats.isNotEmpty() || allTags.isNotEmpty()) { onRefresh = { vm.syncNow() },
Row( modifier = Modifier.padding(pad).fillMaxSize(),
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()) ) {
.padding(horizontal = 12.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
FilterChip(filterCat == null && filterTag == null, { filterCat = null; filterTag = null },
label = { Text("Todas") })
cats.take(12).forEach { c ->
FilterChip(filterCat == c.id, { filterCat = c.id; filterTag = null },
label = { Text("📁 " + (catPaths[c.id] ?: c.name)) })
}
allTags.take(12).forEach { t ->
FilterChip(filterTag == t, { filterTag = t; filterCat = null },
label = { Text("#$t") })
}
}
}
if (shown.isEmpty()) {
Box(Modifier.fillMaxSize().weight(1f), contentAlignment = Alignment.Center) {
Text(
if (notes.isEmpty()) "Nenhuma nota ainda.\nToque em “Nova” — funciona offline."
else "Nada com esse filtro.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 12.dp, end = 12.dp, bottom = 96.dp), contentPadding = PaddingValues(start = 12.dp, end = 12.dp, bottom = 96.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
items(shown, key = { it.id }) { note -> item {
SyncStatusRow(
state = sync,
onSyncNow = { vm.syncNow() },
onSettings = onSettings,
modifier = Modifier.padding(vertical = 4.dp),
)
}
item {
OutlinedTextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("Buscar notas") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(onClick = { query = "" }) {
Icon(Icons.Default.Clear, contentDescription = "Limpar busca")
}
}
},
singleLine = true,
shape = RoundedCornerShape(24.dp),
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
)
}
if (cats.isNotEmpty() || allTags.isNotEmpty()) {
item {
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState())
.padding(vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
FilterChip(filterCat == null && filterTag == null, { filterCat = null; filterTag = null },
label = { Text("Todas") })
cats.take(12).forEach { c ->
FilterChip(filterCat == c.id, { filterCat = c.id; filterTag = null },
label = { Text(catPaths[c.id] ?: c.name) },
leadingIcon = if (filterCat == c.id) null else ({
Icon(Icons.Default.Folder, contentDescription = null,
Modifier.size(16.dp))
}))
}
allTags.take(12).forEach { t ->
FilterChip(filterTag == t, { filterTag = t; filterCat = null },
label = { Text("#$t") })
}
}
}
}
if (shown.isEmpty()) {
item {
EmptyState(
hasNotes = notes.isNotEmpty(),
onNew = onNew,
modifier = Modifier.fillMaxWidth().padding(vertical = 48.dp),
)
}
}
items(shown, key = { it.id }, contentType = { it.format }) { note ->
NoteCard( NoteCard(
note = note, note = note,
catPaths = catPaths, catPaths = catPaths,
others = presence[note.id] ?: 0, others = presence[note.id] ?: 0,
remotelyChanged = remoteChanged.contains(note.id), remotelyChanged = remoteChanged.contains(note.id),
onClick = { onOpen(note.id) }, onClick = { onOpen(note.id) },
onDelete = { deleteTarget = note },
modifier = Modifier.animateItem(),
) )
} }
} }
@@ -308,6 +371,100 @@ fun NotesListScreen(
} }
} }
/** Linha de status M3: chip compacto + progresso, sem banner colorido full-width. */
@Composable
private fun SyncStatusRow(
state: SyncUiState,
onSyncNow: () -> Unit,
onSettings: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
when (state.phase) {
SyncPhase.Syncing -> {
CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
Text("Sincronizando…", style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
SyncPhase.Offline -> AssistChip(
onClick = onSyncNow,
label = { Text("Offline · ${state.pendingCount} na fila") },
leadingIcon = { Icon(Icons.Default.CloudOff, null, Modifier.size(16.dp)) },
)
SyncPhase.Error, SyncPhase.Unauthorized -> AssistChip(
onClick = if (state.phase == SyncPhase.Unauthorized) onSettings else onSyncNow,
label = { Text(if (state.phase == SyncPhase.Unauthorized) "Token inválido" else "Erro: ${state.message}") },
leadingIcon = { Icon(Icons.Default.Warning, null, Modifier.size(16.dp)) },
colors = AssistChipDefaults.assistChipColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
labelColor = MaterialTheme.colorScheme.onErrorContainer,
leadingIconContentColor = MaterialTheme.colorScheme.onErrorContainer,
),
)
SyncPhase.NotConfigured -> AssistChip(
onClick = onSettings,
label = { Text("Configure o servidor") },
leadingIcon = { Icon(Icons.Default.Settings, null, Modifier.size(16.dp)) },
)
SyncPhase.Synced -> {
if (state.pendingCount > 0 || state.dirtyCount > 0) {
AssistChip(
onClick = onSyncNow,
label = { Text("${state.pendingCount + state.dirtyCount} pendente(s)") },
leadingIcon = { Icon(Icons.Default.CloudSync, null, Modifier.size(16.dp)) },
)
} else {
val time = java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT)
.format(java.util.Date(state.lastSyncAt))
Text("Sincronizado às $time", style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (state.conflicts > 0) AssistChip(
onClick = onSyncNow,
label = { Text("${state.conflicts} conflito(s)") },
leadingIcon = { Icon(Icons.Default.Warning, null, Modifier.size(16.dp)) },
colors = AssistChipDefaults.assistChipColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
labelColor = MaterialTheme.colorScheme.onTertiaryContainer,
leadingIconContentColor = MaterialTheme.colorScheme.onTertiaryContainer,
),
)
}
}
}
}
@Composable
private fun EmptyState(hasNotes: Boolean, onNew: () -> Unit, modifier: Modifier = Modifier) {
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)) {
Icon(Icons.AutoMirrored.Filled.NoteAdd, contentDescription = null, Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.surfaceVariant)
Text(
if (!hasNotes) "Nenhuma nota ainda" else "Nada com esse filtro",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
if (!hasNotes) "Toque em Nova — funciona offline."
else "Tente outra busca ou filtro.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (!hasNotes) {
Button(onClick = onNew) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Nova nota")
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
private fun NoteCard( private fun NoteCard(
@@ -316,9 +473,11 @@ private fun NoteCard(
others: Int, others: Int,
remotelyChanged: Boolean, remotelyChanged: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier,
) { ) {
Card(onClick = onClick, shape = RoundedCornerShape(18.dp), elevation = CardDefaults.cardElevation(2.dp)) { Card(onClick = onClick, modifier = modifier, shape = RoundedCornerShape(20.dp)) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
(note.title.ifBlank { "Sem título" }) + extOf(note.format), (note.title.ifBlank { "Sem título" }) + extOf(note.format),
@@ -327,15 +486,25 @@ private fun NoteCard(
maxLines = 1, maxLines = 1,
) )
when { when {
note.pendingDelete -> StatusDot(Color(0xFFE01B24), "exclusão na fila") note.pendingDelete -> StatusDot(MaterialTheme.colorScheme.error, "exclusão na fila")
note.dirty -> StatusDot(Color(0xFFE5A50A), "não enviado") note.dirty -> StatusDot(MaterialTheme.colorScheme.tertiary, "não enviado")
else -> Icon(Icons.Default.CloudDone, "sincronizado", else -> Icon(Icons.Default.CloudDone, "sincronizado",
Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary) Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary)
} }
if (others > 0) { if (others > 0) {
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Text("👥$others", style = MaterialTheme.typography.labelSmall, AssistChip(
color = MaterialTheme.colorScheme.primary) onClick = onClick,
label = { Text("$others", style = MaterialTheme.typography.labelSmall) },
leadingIcon = {
Icon(Icons.Default.Group, contentDescription = "$others pessoas visualizando",
Modifier.size(14.dp))
},
)
}
IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.DeleteOutline, "Excluir", Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.outline)
} }
} }
Text( Text(
@@ -350,9 +519,16 @@ private fun NoteCard(
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)) { verticalArrangement = Arrangement.spacedBy(4.dp)) {
catList.forEach { id -> catList.forEach { id ->
AssistChip(onClick = onClick, label = { AssistChip(
Text("📁 " + (catPaths[id] ?: id), style = MaterialTheme.typography.labelSmall) onClick = onClick,
}) label = {
Text(catPaths[id] ?: id, style = MaterialTheme.typography.labelSmall)
},
leadingIcon = {
Icon(Icons.Default.Folder, contentDescription = null,
Modifier.size(14.dp))
},
)
} }
if (remotelyChanged) WarnChip("mudou remotamente") if (remotelyChanged) WarnChip("mudou remotamente")
if (note.conflictNote.isNotBlank()) WarnChip("conflito resolvido") if (note.conflictNote.isNotBlank()) WarnChip("conflito resolvido")
@@ -399,6 +575,7 @@ fun EditorScreen(vm: NotasViewModel, noteId: String, onBack: () -> Unit) {
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 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 remoteChanged by vm.remoteChanged.collectAsState()
val presence by vm.presence.collectAsState() val presence by vm.presence.collectAsState()
@@ -406,6 +583,28 @@ fun EditorScreen(vm: NotasViewModel, noteId: String, onBack: () -> Unit) {
val keyboard = LocalSoftwareKeyboardController.current val keyboard = LocalSoftwareKeyboardController.current
val dark = isSystemInDarkTheme() 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 = TextFieldValue(it.title) title = TextFieldValue(it.title)
@@ -484,7 +683,16 @@ fun EditorScreen(vm: NotasViewModel, noteId: String, onBack: () -> Unit) {
) )
}, },
actions = { actions = {
if ((presence[noteId] ?: 0) > 0) Text("👥", Modifier.padding(end = 6.dp)) if ((presence[noteId] ?: 0) > 0) {
AssistChip(
onClick = {},
label = { Text("${presence[noteId]}") },
leadingIcon = {
Icon(Icons.Default.Group, contentDescription = "Pessoas nesta nota",
Modifier.size(16.dp))
},
)
}
Text( Text(
savedAt.ifBlank { if (loaded) "—" else "" }, savedAt.ifBlank { if (loaded) "—" else "" },
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
@@ -493,6 +701,9 @@ fun EditorScreen(vm: NotasViewModel, noteId: String, onBack: () -> Unit) {
IconButton(onClick = { persist(); keyboard?.hide() }) { IconButton(onClick = { persist(); keyboard?.hide() }) {
Icon(Icons.Default.CloudDone, "Salvar") Icon(Icons.Default.CloudDone, "Salvar")
} }
IconButton(onClick = { confirmDelete = true }) {
Icon(Icons.Default.Delete, "Excluir", tint = MaterialTheme.colorScheme.outline)
}
}, },
) )
}, },
@@ -3,6 +3,7 @@ package com.labexposed.notas.ui.theme
import android.os.Build import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
@@ -10,8 +11,12 @@ import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// seed Greybird/Mousepad-like // seed Greybird/Mousepad-like (fallback quando dynamic color indisponível)
private val Light = lightColorScheme( private val Light = lightColorScheme(
primary = Color(0xFF1C71D8), primary = Color(0xFF1C71D8),
secondary = Color(0xFF3584E4), secondary = Color(0xFF3584E4),
@@ -24,6 +29,16 @@ private val Dark = darkColorScheme(
secondary = Color(0xFF3584E4), secondary = Color(0xFF3584E4),
) )
private val NotasTypography = Typography(
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp,
),
)
@Composable @Composable
fun NotasTheme(content: @Composable () -> Unit) { fun NotasTheme(content: @Composable () -> Unit) {
val ctx = LocalContext.current val ctx = LocalContext.current
@@ -34,5 +49,9 @@ fun NotasTheme(content: @Composable () -> Unit) {
isSystemInDarkTheme() -> Dark isSystemInDarkTheme() -> Dark
else -> Light else -> Light
} }
MaterialTheme(colorScheme = scheme, content = content) MaterialTheme(
colorScheme = scheme,
typography = NotasTypography,
content = content,
)
} }
@@ -0,0 +1,29 @@
package com.labexposed.notas.ui
import com.labexposed.notas.data.local.NoteEntity
import org.junit.Assert.assertEquals
import org.junit.Test
class NoteSearchTest {
@Test
fun queryMatchesTitleOrContentWithoutCaseSensitivity() {
val notes = listOf(
note("1", title = "Plano semanal", content = "rascunho"),
note("2", title = "Diário", content = "Revisar PLANO amanhã"),
note("3", title = "Compras", content = "leite e pão"),
)
val result = filterNotes(notes, query = "plano")
assertEquals(listOf("1", "2"), result.map { it.id })
}
private fun note(id: String, title: String, content: String) = NoteEntity(
id = id,
title = title,
format = "md",
content = content,
createdAt = 1L,
updatedAt = 1L,
)
}
Binary file not shown.
Vendored Regular → Executable
View File
File mode changed.