Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
641c374a03 | ||
|
|
a8c8a5a998 | ||
|
|
8561ae2f8b | ||
|
|
34a6c38540 | ||
|
|
57edfbe4df | ||
|
|
9508dbf24a | ||
|
|
400ab066b2 |
No files matched your search
@@ -1,5 +1,6 @@
|
|||||||
*.iml
|
*.iml
|
||||||
.gradle/
|
.gradle/
|
||||||
|
.kotlin/
|
||||||
build/
|
build/
|
||||||
local.properties
|
local.properties
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
@@ -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,13 @@ 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 16 (API 36, x86_64, Pixel 6)** — v0.3.1: instalação sem bloqueio de
|
||||||
|
"versão antiga", boot limpo e fluxo config → lista (status, busca, empty state) verificados.
|
||||||
|
- **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);
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ android {
|
|||||||
applicationId = "com.labexposed.notas"
|
applicationId = "com.labexposed.notas"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 1
|
versionCode = 6
|
||||||
versionName = "1.0.0"
|
versionName = "0.3.1"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -42,7 +42,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,
|
||||||
|
)
|
||||||
|
}
|
||||||
Vendored
BIN
Binary file not shown.
Reference in new issue
Block a user