Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81e945b490 | ||
|
|
641c374a03 | ||
|
|
a8c8a5a998 | ||
|
|
8561ae2f8b | ||
|
|
34a6c38540 | ||
|
|
57edfbe4df | ||
|
|
9508dbf24a | ||
|
|
400ab066b2 |
No files matched your search
@@ -1,5 +1,6 @@
|
||||
*.iml
|
||||
.gradle/
|
||||
.kotlin/
|
||||
build/
|
||||
local.properties
|
||||
.idea/
|
||||
|
||||
@@ -40,7 +40,7 @@ unzip -q commandlinetools-linux-*.zip -d ~/Android/Sdk/cmdline-tools
|
||||
mv ~/Android/Sdk/cmdline-tools/cmdline-tools ~/Android/Sdk/cmdline-tools/latest
|
||||
yes | ~/Android/Sdk/cmdline-tools/latest/bin/sdkmanager --licenses
|
||||
~/Android/Sdk/cmdline-tools/latest/bin/sdkmanager \
|
||||
"platform-tools" "platforms;android-35" "build-tools;35.0.0"
|
||||
"platform-tools" "platforms;android-36" "build-tools;36.0.0"
|
||||
|
||||
# 3) ~/.bashrc (ou ~/.zshrc)
|
||||
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk
|
||||
@@ -48,8 +48,8 @@ export ANDROID_HOME=$HOME/Android/Sdk
|
||||
export PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools
|
||||
```
|
||||
|
||||
O projeto usa o **Gradle Wrapper** (8.11.1) — não é preciso instalar Gradle.
|
||||
Se clonar sem os jars do wrapper, gere-os uma vez: `gradle wrapper --gradle-version 8.11.1`
|
||||
O projeto usa o **Gradle Wrapper** (8.13) — não é preciso instalar Gradle.
|
||||
Se clonar sem os jars do wrapper, gere-os uma vez: `gradle wrapper --gradle-version 8.13`
|
||||
(ou abra no Android Studio, que regenera automaticamente).
|
||||
|
||||
## Estrutura do repositório
|
||||
@@ -57,9 +57,9 @@ Se clonar sem os jars do wrapper, gere-os uma vez: `gradle wrapper --gradle-vers
|
||||
```
|
||||
notas-online-android/
|
||||
├── settings.gradle.kts / build.gradle.kts / gradle.properties
|
||||
├── gradle/wrapper/ # Gradle 8.11.1
|
||||
├── gradle/wrapper/ # Gradle 8.13
|
||||
├── app/
|
||||
│ ├── build.gradle.kts # AGP 8.7.3 · Kotlin 2.0.21 · Compose BOM 2024.12
|
||||
│ ├── build.gradle.kts # AGP 8.13.2 · Kotlin 2.2.20 · Compose BOM 2026.06
|
||||
│ ├── proguard-rules.pro
|
||||
│ └── src/main/
|
||||
│ ├── AndroidManifest.xml # INTERNET + ACCESS_NETWORK_STATE; cleartext p/ LAN
|
||||
@@ -106,17 +106,12 @@ notas-online-android/
|
||||
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).
|
||||
|
||||
## 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±
|
||||
pendências/Erro), chips de filtro (Todas / 📁 categoria com caminho da árvore / #tag), cards
|
||||
com título+extensão, tempo relativo, tags, chip de categoria(s) e ícone de status por nota
|
||||
(☁✓ sincronizada, ● âmbar não enviada, 🗑 exclusão na fila, ⚠ conflito/mudança remota, 👥N
|
||||
presença), FAB estendido "Nova".
|
||||
- **Editor**: campo de título na top bar, seletor de modo (Editar / WYSIWYG / Preview), chips de
|
||||
formato (TXT/MD/HTML) e de categorias (multi-seleção), corpo monoespaçado com toolbar de
|
||||
Markdown (B I S H1 H2 • ❝ 🔗 ` { }) que envolve/prefixa a seleção, selo de presença 👥, e
|
||||
horário do último salvamento.
|
||||
- **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".
|
||||
- **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.
|
||||
- **Visual**: cores dinâmicas quando disponíveis, tipografia ajustada e edge-to-edge para integrar conteúdo às barras do sistema.
|
||||
- **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.
|
||||
- **Servidor**: URL + token + autor com *Testar conexão* e validação de esquema http(s).
|
||||
|
||||
## Compilar e instalar
|
||||
@@ -135,8 +130,16 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||
|
||||
## Testado em
|
||||
|
||||
- **Build**: `assembleRelease` limpo (JDK 21 / AGP 8.7.3 / Kotlin 2.0.21 / SDK 35), APK assinado
|
||||
com R8 ≈ **1,6 MB** (debug ≈ 17 MB).
|
||||
- **Build**: `testDebugUnitTest` + `assembleDebug` + `assembleRelease` limpos (JDK 21 / AGP 8.13.2 /
|
||||
Kotlin 2.2.20 / SDK 36 / Compose BOM 2026.06); release R8 ≈ **1,9 MB**, debug ≈ 20 MB.
|
||||
- **Emulador Android 16 (API 36, x86_64, Pixel 6)** — v0.4.0: upgrade in-place sem bloqueio,
|
||||
criar nota → autosave → persiste no Room → lista atualiza; editor WYSIWYG/Preview ok;
|
||||
sem crashes (logcat AndroidRuntime limpo).
|
||||
- **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:
|
||||
- configuração de servidor com *Testar conexão* → OK;
|
||||
- criar nota offline → autosave por ociosidade (10 s) → `POST` único (sem duplicatas);
|
||||
|
||||
+17
-18
@@ -8,14 +8,14 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "com.labexposed.notas"
|
||||
compileSdk = 35
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.labexposed.notas"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "1.0.0"
|
||||
targetSdk = 36
|
||||
versionCode = 7
|
||||
versionName = "0.4.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -42,32 +42,31 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
|
||||
val composeBom = platform("androidx.compose:compose-bom:2026.06.00")
|
||||
implementation(composeBom)
|
||||
|
||||
// UI
|
||||
implementation("androidx.activity:activity-compose:1.9.3")
|
||||
implementation("androidx.activity:activity-compose:1.10.1")
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("androidx.navigation:navigation-compose:2.8.5")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
||||
implementation("androidx.navigation:navigation-compose:2.9.0")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.0")
|
||||
|
||||
// Dados locais
|
||||
implementation("androidx.room:room-runtime:2.6.1")
|
||||
implementation("androidx.room:room-ktx:2.6.1")
|
||||
ksp("androidx.room:room-compiler:2.6.1")
|
||||
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
||||
implementation("androidx.room:room-runtime:2.8.3")
|
||||
implementation("androidx.room:room-ktx:2.8.3")
|
||||
ksp("androidx.room:room-compiler:2.8.3")
|
||||
implementation("androidx.datastore:datastore-preferences:1.1.7")
|
||||
|
||||
// Rede + serialização
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||
|
||||
// Sync em segundo plano
|
||||
implementation("androidx.work:work-runtime-ktx:2.9.1")
|
||||
implementation("androidx.work:work-runtime-ktx:2.10.1")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
||||
}
|
||||
@@ -3,30 +3,16 @@ package com.labexposed.notas
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import com.labexposed.notas.domain.SyncPhase
|
||||
import com.labexposed.notas.domain.SyncUiState
|
||||
import com.labexposed.notas.ui.EditorScreen
|
||||
import com.labexposed.notas.ui.NotesListScreen
|
||||
import com.labexposed.notas.ui.NotasViewModel
|
||||
@@ -36,6 +22,7 @@ import com.labexposed.notas.ui.theme.NotasTheme
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
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.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
@Database(
|
||||
entities = [NoteEntity::class, OutboxEntity::class, CategoryEntity::class, MetaEntity::class],
|
||||
version = 2,
|
||||
version = 3,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class NotasDatabase : RoomDatabase() {
|
||||
@@ -20,6 +22,17 @@ abstract class NotasDatabase : RoomDatabase() {
|
||||
@Volatile
|
||||
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 =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: Room.databaseBuilder(
|
||||
@@ -27,7 +40,7 @@ abstract class NotasDatabase : RoomDatabase() {
|
||||
NotasDatabase::class.java,
|
||||
"notas.db",
|
||||
)
|
||||
.fallbackToDestructiveMigration() // v1→v2: dados de teste; em produção usar migração real
|
||||
.addMigrations(MIGRATION_2_3)
|
||||
.build().also { instance = it }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.labexposed.notas.data.remote
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
@@ -8,7 +9,6 @@ import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Cliente HTTP da API notas-online: Bearer token, timeout curto, retry com
|
||||
* backoff apenas para falhas de rede (IOException), mapeamento de status em
|
||||
@@ -29,7 +29,7 @@ class ApiClient(private val baseUrl: String, private val token: String) {
|
||||
private fun url(path: String) = baseUrl.trimEnd('/') + path
|
||||
|
||||
/** 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,
|
||||
path: String,
|
||||
body: String? = null,
|
||||
@@ -65,7 +65,7 @@ class ApiClient(private val baseUrl: String, private val token: String) {
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
lastError = e
|
||||
if (attempt == 0) Thread.sleep(500)
|
||||
if (attempt == 0) delay(500)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// URL malformada (configuração errada): não adianta repetir
|
||||
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")
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
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))
|
||||
|
||||
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 r = call<RenderDto>("POST", "/api/render", body)
|
||||
return if (r is ApiResult.Ok) ApiResult.Ok((r.value as RenderDto).html) else r as ApiResult<Nothing>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package com.labexposed.notas.data.remote
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.job
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
@@ -29,6 +33,8 @@ class SseClient(private val baseUrl: String, private val token: String, private
|
||||
try {
|
||||
streamOnce(onEvent)
|
||||
backoff = 1_000L // stream terminou limpo (scope cancelado não chega aqui)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
// rede caiu / servidor fora: espera e tenta de novo
|
||||
}
|
||||
@@ -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()
|
||||
.url(baseUrl.trimEnd('/') + "/api/events?session=" + sessionId)
|
||||
.header("Authorization", "Bearer $token")
|
||||
.header("Accept", "text/event-stream")
|
||||
.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}")
|
||||
val reader = resp.body?.source()?.inputStream()?.bufferedReader() ?: throw java.io.IOException("sem corpo")
|
||||
var data = StringBuilder()
|
||||
while (true) {
|
||||
currentCoroutineContext().ensureActive()
|
||||
val line = reader.readLine() ?: break
|
||||
when {
|
||||
line.startsWith("data: ") -> data.append(line.removePrefix("data: "))
|
||||
line.startsWith("data:") -> data.append(line.removePrefix("data:").trimStart())
|
||||
line.isEmpty() && data.isNotEmpty() -> {
|
||||
val raw = data.toString()
|
||||
data = StringBuilder()
|
||||
|
||||
@@ -51,6 +51,8 @@ class SyncEngine(
|
||||
SyncStatusStore.syncMutex.withLock {
|
||||
try {
|
||||
runSync(trigger)
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("SyncEngine", "fullSync($trigger) falhou", e)
|
||||
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")) {
|
||||
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()
|
||||
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
|
||||
|
||||
import com.labexposed.notas.SyncBanner
|
||||
import com.labexposed.notas.domain.Settings
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import com.labexposed.notas.domain.Settings as ServerSettings
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.CloudOff
|
||||
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.Edit
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.AssistChipDefaults
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedAssistChip
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -51,6 +57,7 @@ import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -161,14 +168,14 @@ fun SettingsScreen(vm: NotasViewModel, onDone: () -> Unit) {
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
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") }
|
||||
Spacer(Modifier.weight(1f))
|
||||
androidx.compose.material3.Button(
|
||||
enabled = url.text.isNotBlank() && token.text.isNotBlank() &&
|
||||
(url.text.startsWith("http://") || url.text.startsWith("https://")),
|
||||
onClick = {
|
||||
vm.saveSettings(Settings(url.text, token.text, author.text))
|
||||
vm.saveSettings(ServerSettings(url.text, token.text, author.text))
|
||||
onDone()
|
||||
},
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
@@ -195,7 +202,32 @@ fun NotesListScreen(
|
||||
val remoteChanged by vm.remoteChanged.collectAsState()
|
||||
var filterCat 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 scope = rememberCoroutineScope()
|
||||
deleteTarget?.let { target ->
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { deleteTarget = null },
|
||||
icon = { Icon(Icons.Default.Delete, null) },
|
||||
title = { Text("Excluir nota?") },
|
||||
text = {
|
||||
Text("“${target.title.ifBlank { "Sem título" }}” será removida deste aparelho " +
|
||||
"e do servidor na próxima sincronização (o histórico dela fica nas revisões " +
|
||||
"do servidor até a exclusão subir). Esta ação não pode ser desfeita pelo app.")
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
vm.deleteNote(target.id)
|
||||
deleteTarget = null
|
||||
scope.launch { snackbar.showSnackbar("Exclusão registrada — sincronizando") }
|
||||
}) { Text("Excluir", color = MaterialTheme.colorScheme.error) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { deleteTarget = null }) { Text("Cancelar") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val catPaths = remember(cats) {
|
||||
val byId = cats.associateBy { it.id }
|
||||
@@ -214,13 +246,9 @@ fun NotesListScreen(
|
||||
val allTags = remember(notes) {
|
||||
notes.flatMap { it.tags.split(",").filter { t -> t.isNotBlank() } }.distinct().sorted()
|
||||
}
|
||||
val shown = remember(notes, filterCat, filterTag) {
|
||||
notes.filter { n ->
|
||||
(filterCat == null || n.categories.split(",").contains(filterCat)) &&
|
||||
(filterTag == null || n.tags.split(",").contains(filterTag))
|
||||
}
|
||||
val shown = remember(notes, filterCat, filterTag, query) {
|
||||
filterNotes(notes, query, filterCat, filterTag)
|
||||
}
|
||||
|
||||
val events by vm.events.collectAsState()
|
||||
LaunchedEffect(events) {
|
||||
if (events is ListEvent.RemoteChange) {
|
||||
@@ -228,7 +256,6 @@ fun NotesListScreen(
|
||||
vm.consumeEvent()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbar) },
|
||||
topBar = {
|
||||
@@ -259,48 +286,84 @@ fun NotesListScreen(
|
||||
)
|
||||
},
|
||||
) { pad ->
|
||||
Column(modifier = Modifier.padding(pad).fillMaxSize()) {
|
||||
SyncBanner(sync, Modifier.padding(horizontal = 12.dp))
|
||||
if (cats.isNotEmpty() || allTags.isNotEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
FilterChip(filterCat == null && filterTag == null, { filterCat = null; filterTag = null },
|
||||
label = { Text("Todas") })
|
||||
cats.take(12).forEach { c ->
|
||||
FilterChip(filterCat == c.id, { filterCat = c.id; filterTag = null },
|
||||
label = { Text("📁 " + (catPaths[c.id] ?: c.name)) })
|
||||
}
|
||||
allTags.take(12).forEach { t ->
|
||||
FilterChip(filterTag == t, { filterTag = t; filterCat = null },
|
||||
label = { Text("#$t") })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (shown.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize().weight(1f), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
if (notes.isEmpty()) "Nenhuma nota ainda.\nToque em “Nova” — funciona offline."
|
||||
else "Nada com esse filtro.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
PullToRefreshBox(
|
||||
isRefreshing = sync.phase == SyncPhase.Syncing,
|
||||
onRefresh = { vm.syncNow() },
|
||||
modifier = Modifier.padding(pad).fillMaxSize(),
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(start = 12.dp, end = 12.dp, bottom = 96.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(
|
||||
note = note,
|
||||
catPaths = catPaths,
|
||||
others = presence[note.id] ?: 0,
|
||||
remotelyChanged = remoteChanged.contains(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)
|
||||
@Composable
|
||||
private fun NoteCard(
|
||||
@@ -316,9 +473,11 @@ private fun NoteCard(
|
||||
others: Int,
|
||||
remotelyChanged: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(onClick = onClick, shape = RoundedCornerShape(18.dp), elevation = CardDefaults.cardElevation(2.dp)) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Card(onClick = onClick, modifier = modifier, shape = RoundedCornerShape(20.dp)) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
(note.title.ifBlank { "Sem título" }) + extOf(note.format),
|
||||
@@ -327,15 +486,25 @@ private fun NoteCard(
|
||||
maxLines = 1,
|
||||
)
|
||||
when {
|
||||
note.pendingDelete -> StatusDot(Color(0xFFE01B24), "exclusão na fila")
|
||||
note.dirty -> StatusDot(Color(0xFFE5A50A), "não enviado")
|
||||
note.pendingDelete -> StatusDot(MaterialTheme.colorScheme.error, "exclusão na fila")
|
||||
note.dirty -> StatusDot(MaterialTheme.colorScheme.tertiary, "não enviado")
|
||||
else -> Icon(Icons.Default.CloudDone, "sincronizado",
|
||||
Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
if (others > 0) {
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("👥$others", style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary)
|
||||
AssistChip(
|
||||
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(
|
||||
@@ -350,9 +519,16 @@ private fun NoteCard(
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
catList.forEach { id ->
|
||||
AssistChip(onClick = onClick, label = {
|
||||
Text("📁 " + (catPaths[id] ?: id), style = MaterialTheme.typography.labelSmall)
|
||||
})
|
||||
AssistChip(
|
||||
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 (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 savedAt by remember { mutableStateOf("") }
|
||||
var mode by remember { mutableStateOf(EditorMode.Edit) }
|
||||
var confirmDelete by remember { mutableStateOf(false) }
|
||||
val cats by vm.categories.collectAsState()
|
||||
val remoteChanged by vm.remoteChanged.collectAsState()
|
||||
val presence by vm.presence.collectAsState()
|
||||
@@ -406,6 +583,28 @@ fun EditorScreen(vm: NotasViewModel, noteId: String, onBack: () -> Unit) {
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
val dark = isSystemInDarkTheme()
|
||||
|
||||
if (confirmDelete) {
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { confirmDelete = false },
|
||||
icon = { Icon(Icons.Default.Delete, null) },
|
||||
title = { Text("Excluir nota?") },
|
||||
text = {
|
||||
Text("“${title.text.ifBlank { "Sem título" }}” será removida deste aparelho e do " +
|
||||
"servidor na próxima sincronização. Esta ação não pode ser desfeita pelo app.")
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
confirmDelete = false
|
||||
vm.deleteNote(noteId)
|
||||
onBack()
|
||||
}) { Text("Excluir", color = MaterialTheme.colorScheme.error) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { confirmDelete = false }) { Text("Cancelar") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(noteId) {
|
||||
vm.noteById(noteId)?.let {
|
||||
title = TextFieldValue(it.title)
|
||||
@@ -484,7 +683,16 @@ fun EditorScreen(vm: NotasViewModel, noteId: String, onBack: () -> Unit) {
|
||||
)
|
||||
},
|
||||
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(
|
||||
savedAt.ifBlank { if (loaded) "—" else "" },
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
@@ -493,6 +701,9 @@ fun EditorScreen(vm: NotasViewModel, noteId: String, onBack: () -> Unit) {
|
||||
IconButton(onClick = { persist(); keyboard?.hide() }) {
|
||||
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 androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
@@ -10,8 +11,12 @@ import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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(
|
||||
primary = Color(0xFF1C71D8),
|
||||
secondary = Color(0xFF3584E4),
|
||||
@@ -24,6 +29,16 @@ private val Dark = darkColorScheme(
|
||||
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
|
||||
fun NotasTheme(content: @Composable () -> Unit) {
|
||||
val ctx = LocalContext.current
|
||||
@@ -34,5 +49,9 @@ fun NotasTheme(content: @Composable () -> Unit) {
|
||||
isSystemInDarkTheme() -> Dark
|
||||
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,
|
||||
)
|
||||
}
|
||||
+5
-5
@@ -1,8 +1,8 @@
|
||||
// Arquivo raiz — só declara plugins; versão centralizada aqui.
|
||||
plugins {
|
||||
id("com.android.application") version "8.7.3" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
|
||||
id("com.google.devtools.ksp") version "2.0.21-1.0.28" apply false
|
||||
id("com.android.application") version "8.13.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.2.20" apply false
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.2.20" apply false
|
||||
id("com.google.devtools.ksp") version "2.2.20-2.0.4" apply false
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
+1
-1
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
Reference in new issue
Block a user