Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cccba68036 | ||
|
|
d10853bcaa | ||
|
|
e608493c81 | ||
|
|
81e945b490 | ||
|
|
641c374a03 | ||
|
|
a8c8a5a998 |
No files matched your search
@@ -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
|
mv ~/Android/Sdk/cmdline-tools/cmdline-tools ~/Android/Sdk/cmdline-tools/latest
|
||||||
yes | ~/Android/Sdk/cmdline-tools/latest/bin/sdkmanager --licenses
|
yes | ~/Android/Sdk/cmdline-tools/latest/bin/sdkmanager --licenses
|
||||||
~/Android/Sdk/cmdline-tools/latest/bin/sdkmanager \
|
~/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)
|
# 3) ~/.bashrc (ou ~/.zshrc)
|
||||||
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk
|
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
|
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.
|
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.11.1`
|
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).
|
(ou abra no Android Studio, que regenera automaticamente).
|
||||||
|
|
||||||
## Estrutura do repositório
|
## 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/
|
notas-online-android/
|
||||||
├── settings.gradle.kts / build.gradle.kts / gradle.properties
|
├── settings.gradle.kts / build.gradle.kts / gradle.properties
|
||||||
├── gradle/wrapper/ # Gradle 8.11.1
|
├── gradle/wrapper/ # Gradle 8.13
|
||||||
├── app/
|
├── 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
|
│ ├── proguard-rules.pro
|
||||||
│ └── src/main/
|
│ └── src/main/
|
||||||
│ ├── AndroidManifest.xml # INTERNET + ACCESS_NETWORK_STATE; cleartext p/ LAN
|
│ ├── AndroidManifest.xml # INTERNET + ACCESS_NETWORK_STATE; cleartext p/ LAN
|
||||||
@@ -106,21 +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), **botão de excluir por card** (🗑) com diálogo de confirmação, 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 👥,
|
|
||||||
horário do último salvamento e **botão de excluir** (com confirmação; volta para a lista).
|
|
||||||
- **Exclusões sincronizam**: apagar marca `pendingDelete`, entra `delete` na fila e sobe na
|
|
||||||
próxima sync (nota local-only some direto); outras sessões recebem o evento SSE `deleted` e a
|
|
||||||
nota some delas também se estiver limpa. Se a nota já sumiu do servidor (404), a fila limpa
|
|
||||||
sozinha.
|
|
||||||
- **Servidor**: URL + token + autor com *Testar conexão* e validação de esquema http(s).
|
- **Servidor**: URL + token + autor com *Testar conexão* e validação de esquema http(s).
|
||||||
|
|
||||||
## Compilar e instalar
|
## Compilar e instalar
|
||||||
@@ -139,8 +130,17 @@ 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
|
- **Emulador Android 16 (API 36, x86_64, Pixel 6)** — v0.4.1: build release R8 limpo,
|
||||||
com R8 ≈ **1,6 MB** (debug ≈ 17 MB).
|
`testDebugUnitTest` verde; push/pull 5xx → `Transient` com retry, `Failed` → Error,
|
||||||
|
SSE multilinha, sem crashes.
|
||||||
|
- **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:
|
- **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);
|
||||||
@@ -165,3 +165,5 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk
|
|||||||
- Sync reativo incremental (o pull compara hashes por nota; milhares de notas pediriam um
|
- Sync reativo incremental (o pull compara hashes por nota; milhares de notas pediriam um
|
||||||
endpoint de delta no servidor).
|
endpoint de delta no servidor).
|
||||||
- Multi-perfil/servidores e sincronização de anexos.
|
- Multi-perfil/servidores e sincronização de anexos.
|
||||||
|
- Material 3 Expressive completo (`MaterialExpressiveTheme` + componentes 1.5.x) quando a
|
||||||
|
linha 1.5 sair de beta — hoje o estável (material3 1.4.0) não expõe a API.
|
||||||
+17
-17
@@ -8,13 +8,14 @@ plugins {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.labexposed.notas"
|
namespace = "com.labexposed.notas"
|
||||||
compileSdk = 35
|
compileSdk = 36
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.labexposed.notas"
|
applicationId = "com.labexposed.notas"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
versionCode = 4
|
targetSdk = 36
|
||||||
versionName = "0.2.2"
|
versionCode = 8
|
||||||
|
versionName = "0.4.1"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -41,32 +42,31 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
|
val composeBom = platform("androidx.compose:compose-bom:2026.06.00")
|
||||||
implementation(composeBom)
|
implementation(composeBom)
|
||||||
|
|
||||||
// UI
|
// 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.ui:ui")
|
||||||
implementation("androidx.compose.material3:material3")
|
implementation("androidx.compose.material3:material3")
|
||||||
implementation("androidx.compose.material:material-icons-extended")
|
implementation("androidx.compose.material:material-icons-extended")
|
||||||
implementation("androidx.navigation:navigation-compose:2.8.5")
|
implementation("androidx.navigation:navigation-compose:2.9.0")
|
||||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.0")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.0")
|
||||||
|
|
||||||
// Dados locais
|
// Dados locais
|
||||||
implementation("androidx.room:room-runtime:2.6.1")
|
implementation("androidx.room:room-runtime:2.8.3")
|
||||||
implementation("androidx.room:room-ktx:2.6.1")
|
implementation("androidx.room:room-ktx:2.8.3")
|
||||||
ksp("androidx.room:room-compiler:2.6.1")
|
ksp("androidx.room:room-compiler:2.8.3")
|
||||||
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
implementation("androidx.datastore:datastore-preferences:1.1.7")
|
||||||
|
|
||||||
// Rede + serialização
|
|
||||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||||
|
|
||||||
// Sync em segundo plano
|
// 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("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 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)
|
|
||||||
@@ -59,8 +59,11 @@ class SseClient(private val baseUrl: String, private val token: String, private
|
|||||||
currentCoroutineContext().ensureActive()
|
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:") -> {
|
||||||
line.startsWith("data:") -> data.append(line.removePrefix("data:").trimStart())
|
// evento multilinha: junta os blocos com \n (spec SSE)
|
||||||
|
if (data.isNotEmpty()) data.append('\n')
|
||||||
|
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()
|
||||||
|
|||||||
@@ -22,5 +22,7 @@ sealed class SyncOutcome {
|
|||||||
data class Success(val pushed: Int, val pulled: Int, val conflicts: Int) : SyncOutcome()
|
data class Success(val pushed: Int, val pulled: Int, val conflicts: Int) : SyncOutcome()
|
||||||
data class NetworkDown(val message: String) : SyncOutcome()
|
data class NetworkDown(val message: String) : SyncOutcome()
|
||||||
data object Unauthorized : SyncOutcome()
|
data object Unauthorized : SyncOutcome()
|
||||||
|
/** Erro transitório do servidor (5xx): pode repetir depois. */
|
||||||
|
data class Transient(val message: String) : SyncOutcome()
|
||||||
data class Failed(val message: String) : SyncOutcome()
|
data class Failed(val message: String) : SyncOutcome()
|
||||||
}
|
}
|
||||||
@@ -81,6 +81,19 @@ class SyncEngine(
|
|||||||
status.set { it.copy(phase = SyncPhase.Offline, message = push.message) }
|
status.set { it.copy(phase = SyncPhase.Offline, message = push.message) }
|
||||||
return push
|
return push
|
||||||
}
|
}
|
||||||
|
if (push is SyncOutcome.Transient) {
|
||||||
|
status.set { it.copy(phase = SyncPhase.Offline, message = push.message) }
|
||||||
|
return push
|
||||||
|
}
|
||||||
|
|
||||||
|
if (push is SyncOutcome.Failed) {
|
||||||
|
status.set { it.copy(phase = SyncPhase.Error, message = push.message) }
|
||||||
|
return push
|
||||||
|
}
|
||||||
|
if (push is SyncOutcome.NotConfigured) {
|
||||||
|
status.set { it.copy(phase = SyncPhase.NotConfigured) }
|
||||||
|
return push
|
||||||
|
}
|
||||||
|
|
||||||
val pull = pull(client)
|
val pull = pull(client)
|
||||||
if (pull is SyncOutcome.Unauthorized) {
|
if (pull is SyncOutcome.Unauthorized) {
|
||||||
@@ -91,6 +104,14 @@ class SyncEngine(
|
|||||||
status.set { it.copy(phase = SyncPhase.Offline, message = pull.message) }
|
status.set { it.copy(phase = SyncPhase.Offline, message = pull.message) }
|
||||||
return pull
|
return pull
|
||||||
}
|
}
|
||||||
|
if (pull is SyncOutcome.Transient) {
|
||||||
|
status.set { it.copy(phase = SyncPhase.Offline, message = pull.message) }
|
||||||
|
return pull
|
||||||
|
}
|
||||||
|
if (pull is SyncOutcome.Failed) {
|
||||||
|
status.set { it.copy(phase = SyncPhase.Error, message = pull.message) }
|
||||||
|
return pull
|
||||||
|
}
|
||||||
|
|
||||||
val ok = (push as SyncOutcome.Success) to (pull as SyncOutcome.Success)
|
val ok = (push as SyncOutcome.Success) to (pull as SyncOutcome.Success)
|
||||||
val conflicts = ok.first.conflicts + ok.second.conflicts
|
val conflicts = ok.first.conflicts + ok.second.conflicts
|
||||||
@@ -151,6 +172,7 @@ class SyncEngine(
|
|||||||
}
|
}
|
||||||
is ApiResult.Network -> return SyncOutcome.NetworkDown(resp.message)
|
is ApiResult.Network -> return SyncOutcome.NetworkDown(resp.message)
|
||||||
is ApiResult.Unauthorized -> return SyncOutcome.Unauthorized
|
is ApiResult.Unauthorized -> return SyncOutcome.Unauthorized
|
||||||
|
is ApiResult.Server -> return serverOutcome("create ${note.id}", resp)
|
||||||
else -> return SyncOutcome.Failed("create ${note.id}: ${describe(resp)}")
|
else -> return SyncOutcome.Failed("create ${note.id}: ${describe(resp)}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,6 +211,7 @@ class SyncEngine(
|
|||||||
}
|
}
|
||||||
is ApiResult.Network -> return SyncOutcome.NetworkDown(resp.message)
|
is ApiResult.Network -> return SyncOutcome.NetworkDown(resp.message)
|
||||||
is ApiResult.Unauthorized -> return SyncOutcome.Unauthorized
|
is ApiResult.Unauthorized -> return SyncOutcome.Unauthorized
|
||||||
|
is ApiResult.Server -> return serverOutcome("update ${note.id}", resp)
|
||||||
else -> return SyncOutcome.Failed("update ${note.id}: ${describe(resp)}")
|
else -> return SyncOutcome.Failed("update ${note.id}: ${describe(resp)}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,6 +223,7 @@ class SyncEngine(
|
|||||||
is ApiResult.Ok, is ApiResult.NotFound -> Unit
|
is ApiResult.Ok, is ApiResult.NotFound -> Unit
|
||||||
is ApiResult.Network -> return SyncOutcome.NetworkDown(resp.message)
|
is ApiResult.Network -> return SyncOutcome.NetworkDown(resp.message)
|
||||||
is ApiResult.Unauthorized -> return SyncOutcome.Unauthorized
|
is ApiResult.Unauthorized -> return SyncOutcome.Unauthorized
|
||||||
|
is ApiResult.Server -> return serverOutcome("delete ${note.id}", resp)
|
||||||
else -> return SyncOutcome.Failed("delete ${note.id}: ${describe(resp)}")
|
else -> return SyncOutcome.Failed("delete ${note.id}: ${describe(resp)}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -290,12 +314,14 @@ class SyncEngine(
|
|||||||
}
|
}
|
||||||
is ApiResult.Network -> return SyncOutcome.NetworkDown(cats.message)
|
is ApiResult.Network -> return SyncOutcome.NetworkDown(cats.message)
|
||||||
is ApiResult.Unauthorized -> return SyncOutcome.Unauthorized
|
is ApiResult.Unauthorized -> return SyncOutcome.Unauthorized
|
||||||
|
is ApiResult.Server -> return serverOutcome("categories", cats)
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
|
|
||||||
val list = client.listNotes()
|
val list = client.listNotes()
|
||||||
if (list is ApiResult.Network) return SyncOutcome.NetworkDown(list.message)
|
if (list is ApiResult.Network) return SyncOutcome.NetworkDown(list.message)
|
||||||
if (list is ApiResult.Unauthorized) return SyncOutcome.Unauthorized
|
if (list is ApiResult.Unauthorized) return SyncOutcome.Unauthorized
|
||||||
|
if (list is ApiResult.Server) return serverOutcome("list", list)
|
||||||
if (list !is ApiResult.Ok) return SyncOutcome.Failed("list: ${describe(list)}")
|
if (list !is ApiResult.Ok) return SyncOutcome.Failed("list: ${describe(list)}")
|
||||||
|
|
||||||
val serverIds = list.value.map { it.id }.toSet()
|
val serverIds = list.value.map { it.id }.toSet()
|
||||||
@@ -345,6 +371,10 @@ class SyncEngine(
|
|||||||
|
|
||||||
private fun NoteDetailDto.toEntity() = toNoteEntity()
|
private fun NoteDetailDto.toEntity() = toNoteEntity()
|
||||||
|
|
||||||
|
private fun serverOutcome(prefix: String, r: ApiResult.Server): SyncOutcome =
|
||||||
|
if (r.code >= 500) SyncOutcome.Transient("$prefix: HTTP ${r.code} ${r.message}")
|
||||||
|
else SyncOutcome.Failed("$prefix: HTTP ${r.code} ${r.message}")
|
||||||
|
|
||||||
private fun describe(r: ApiResult<*>): String = when (r) {
|
private fun describe(r: ApiResult<*>): String = when (r) {
|
||||||
is ApiResult.Server -> "HTTP ${r.code} ${r.message}"
|
is ApiResult.Server -> "HTTP ${r.code} ${r.message}"
|
||||||
is ApiResult.Network -> r.message
|
is ApiResult.Network -> r.message
|
||||||
|
|||||||
@@ -30,6 +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.Transient -> Result.retry()
|
||||||
is com.labexposed.notas.domain.SyncOutcome.Failed -> Result.failure()
|
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
|
||||||
@@ -31,16 +30,22 @@ 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.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
|
||||||
@@ -52,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
|
||||||
@@ -162,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),
|
||||||
@@ -196,10 +202,10 @@ 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) }
|
var deleteTarget by remember { mutableStateOf<NoteEntity?>(null) }
|
||||||
val snackbar = remember { SnackbarHostState() }
|
val snackbar = remember { SnackbarHostState() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
deleteTarget?.let { target ->
|
deleteTarget?.let { target ->
|
||||||
androidx.compose.material3.AlertDialog(
|
androidx.compose.material3.AlertDialog(
|
||||||
onDismissRequest = { deleteTarget = null },
|
onDismissRequest = { deleteTarget = null },
|
||||||
@@ -240,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) {
|
||||||
@@ -254,7 +256,6 @@ fun NotesListScreen(
|
|||||||
vm.consumeEvent()
|
vm.consumeEvent()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
snackbarHost = { SnackbarHost(snackbar) },
|
snackbarHost = { SnackbarHost(snackbar) },
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -285,42 +286,76 @@ 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,
|
||||||
@@ -328,6 +363,7 @@ fun NotesListScreen(
|
|||||||
remotelyChanged = remoteChanged.contains(note.id),
|
remotelyChanged = remoteChanged.contains(note.id),
|
||||||
onClick = { onOpen(note.id) },
|
onClick = { onOpen(note.id) },
|
||||||
onDelete = { deleteTarget = note },
|
onDelete = { deleteTarget = note },
|
||||||
|
modifier = Modifier.animateItem(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -335,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(
|
||||||
@@ -344,9 +474,10 @@ private fun NoteCard(
|
|||||||
remotelyChanged: Boolean,
|
remotelyChanged: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onDelete: () -> 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),
|
||||||
@@ -355,18 +486,24 @@ 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)) {
|
IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) {
|
||||||
Icon(Icons.Default.Delete, "Excluir", Modifier.size(18.dp),
|
Icon(Icons.Default.DeleteOutline, "Excluir", Modifier.size(18.dp),
|
||||||
tint = MaterialTheme.colorScheme.outline)
|
tint = MaterialTheme.colorScheme.outline)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -382,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")
|
||||||
@@ -539,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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
+5
-5
@@ -1,8 +1,8 @@
|
|||||||
// Arquivo raiz — só declara plugins; versão centralizada aqui.
|
// Arquivo raiz — só declara plugins; versão centralizada aqui.
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application") version "8.7.3" apply false
|
id("com.android.application") version "8.13.2" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
|
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||||
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
|
id("org.jetbrains.kotlin.plugin.compose") version "2.2.20" apply false
|
||||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
|
id("org.jetbrains.kotlin.plugin.serialization") version "2.2.20" apply false
|
||||||
id("com.google.devtools.ksp") version "2.0.21-1.0.28" apply false
|
id("com.google.devtools.ksp") version "2.2.20-2.0.4" apply false
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
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
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
Reference in new issue
Block a user