Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8561ae2f8b | ||
|
|
34a6c38540 | ||
|
|
57edfbe4df | ||
|
|
9508dbf24a | ||
|
|
400ab066b2 |
No files matched your search
@@ -1,5 +1,6 @@
|
|||||||
*.iml
|
*.iml
|
||||||
.gradle/
|
.gradle/
|
||||||
|
.kotlin/
|
||||||
build/
|
build/
|
||||||
local.properties
|
local.properties
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
@@ -112,11 +112,15 @@ notas-online-android/
|
|||||||
pendências/Erro), chips de filtro (Todas / 📁 categoria com caminho da árvore / #tag), cards
|
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
|
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
|
(☁✓ sincronizada, ● âmbar não enviada, 🗑 exclusão na fila, ⚠ conflito/mudança remota, 👥N
|
||||||
presença), FAB estendido "Nova".
|
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
|
- **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
|
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
|
Markdown (B I S H1 H2 • ❝ 🔗 ` { }) que envolve/prefixa a seleção, selo de presença 👥,
|
||||||
horário do último salvamento.
|
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
|
||||||
|
|||||||
@@ -13,9 +13,8 @@ android {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.labexposed.notas"
|
applicationId = "com.labexposed.notas"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
versionCode = 4
|
||||||
versionCode = 1
|
versionName = "0.2.2"
|
||||||
versionName = "1.0.0"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import android.content.Context
|
|||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [NoteEntity::class, OutboxEntity::class, CategoryEntity::class, MetaEntity::class],
|
entities = [NoteEntity::class, OutboxEntity::class, CategoryEntity::class, MetaEntity::class],
|
||||||
version = 2,
|
version = 3,
|
||||||
exportSchema = false,
|
exportSchema = false,
|
||||||
)
|
)
|
||||||
abstract class NotasDatabase : RoomDatabase() {
|
abstract class NotasDatabase : RoomDatabase() {
|
||||||
@@ -20,6 +22,17 @@ abstract class NotasDatabase : RoomDatabase() {
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var instance: NotasDatabase? = null
|
private var instance: NotasDatabase? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1→v2→v3: a mudança real foi o índice único (noteId, op) na outbox
|
||||||
|
* (v2). Migração recria o índice sem tocar nos dados — a fila não
|
||||||
|
* enviada sobrevive ao upgrade, ao contrário do destructive fallback.
|
||||||
|
*/
|
||||||
|
val MIGRATION_2_3: Migration = object : Migration(2, 3) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_outbox_noteId_op` ON `outbox` (`noteId`, `op`)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun get(context: Context): NotasDatabase =
|
fun get(context: Context): NotasDatabase =
|
||||||
instance ?: synchronized(this) {
|
instance ?: synchronized(this) {
|
||||||
instance ?: Room.databaseBuilder(
|
instance ?: Room.databaseBuilder(
|
||||||
@@ -27,7 +40,7 @@ abstract class NotasDatabase : RoomDatabase() {
|
|||||||
NotasDatabase::class.java,
|
NotasDatabase::class.java,
|
||||||
"notas.db",
|
"notas.db",
|
||||||
)
|
)
|
||||||
.fallbackToDestructiveMigration() // v1→v2: dados de teste; em produção usar migração real
|
.addMigrations(MIGRATION_2_3)
|
||||||
.build().also { instance = it }
|
.build().also { instance = it }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.labexposed.notas.data.remote
|
package com.labexposed.notas.data.remote
|
||||||
|
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
@@ -8,7 +9,6 @@ import okhttp3.Request
|
|||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cliente HTTP da API notas-online: Bearer token, timeout curto, retry com
|
* Cliente HTTP da API notas-online: Bearer token, timeout curto, retry com
|
||||||
* backoff apenas para falhas de rede (IOException), mapeamento de status em
|
* backoff apenas para falhas de rede (IOException), mapeamento de status em
|
||||||
@@ -29,7 +29,7 @@ class ApiClient(private val baseUrl: String, private val token: String) {
|
|||||||
private fun url(path: String) = baseUrl.trimEnd('/') + path
|
private fun url(path: String) = baseUrl.trimEnd('/') + path
|
||||||
|
|
||||||
/** Executa com até 2 tentativas; erros de rede/URL inválida viram Network. */
|
/** Executa com até 2 tentativas; erros de rede/URL inválida viram Network. */
|
||||||
private inline fun <reified T> call(
|
private suspend inline fun <reified T> call(
|
||||||
method: String,
|
method: String,
|
||||||
path: String,
|
path: String,
|
||||||
body: String? = null,
|
body: String? = null,
|
||||||
@@ -65,7 +65,7 @@ class ApiClient(private val baseUrl: String, private val token: String) {
|
|||||||
}
|
}
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
lastError = e
|
lastError = e
|
||||||
if (attempt == 0) Thread.sleep(500)
|
if (attempt == 0) delay(500)
|
||||||
} catch (e: IllegalArgumentException) {
|
} catch (e: IllegalArgumentException) {
|
||||||
// URL malformada (configuração errada): não adianta repetir
|
// URL malformada (configuração errada): não adianta repetir
|
||||||
return ApiResult.Network("URL inválida: ${e.message}")
|
return ApiResult.Network("URL inválida: ${e.message}")
|
||||||
@@ -74,23 +74,23 @@ class ApiClient(private val baseUrl: String, private val token: String) {
|
|||||||
return ApiResult.Network(lastError?.message ?: "sem conexão")
|
return ApiResult.Network(lastError?.message ?: "sem conexão")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun testConnection(): ApiResult<List<NoteSummaryDto>> = call("GET", "/api/notes")
|
suspend fun testConnection(): ApiResult<List<NoteSummaryDto>> = call("GET", "/api/notes")
|
||||||
|
|
||||||
fun listNotes(): ApiResult<List<NoteSummaryDto>> = call("GET", "/api/notes")
|
suspend fun listNotes(): ApiResult<List<NoteSummaryDto>> = call("GET", "/api/notes")
|
||||||
|
|
||||||
fun listCategories(): ApiResult<List<CategoryDto>> = call("GET", "/api/categories")
|
suspend fun listCategories(): ApiResult<List<CategoryDto>> = call("GET", "/api/categories")
|
||||||
|
|
||||||
fun getNote(id: String): ApiResult<NoteDetailDto> = call("GET", "/api/notes/$id")
|
suspend fun getNote(id: String): ApiResult<NoteDetailDto> = call("GET", "/api/notes/$id")
|
||||||
|
|
||||||
fun createNote(dto: NoteWriteDto): ApiResult<NoteDetailDto> =
|
suspend fun createNote(dto: NoteWriteDto): ApiResult<NoteDetailDto> =
|
||||||
call("POST", "/api/notes", json.encodeToString(dto))
|
call("POST", "/api/notes", json.encodeToString(dto))
|
||||||
|
|
||||||
fun updateNote(id: String, dto: NoteWriteDto): ApiResult<NoteDetailDto> =
|
suspend fun updateNote(id: String, dto: NoteWriteDto): ApiResult<NoteDetailDto> =
|
||||||
call("PUT", "/api/notes/$id", json.encodeToString(dto))
|
call("PUT", "/api/notes/$id", json.encodeToString(dto))
|
||||||
|
|
||||||
fun deleteNote(id: String): ApiResult<Unit> = call("DELETE", "/api/notes/$id")
|
suspend fun deleteNote(id: String): ApiResult<Unit> = call("DELETE", "/api/notes/$id")
|
||||||
|
|
||||||
fun renderMd(content: String): ApiResult<String> {
|
suspend fun renderMd(content: String): ApiResult<String> {
|
||||||
val body = """{"content":${json.encodeToString(content)},"format":"md"}"""
|
val body = """{"content":${json.encodeToString(content)},"format":"md"}"""
|
||||||
val r = call<RenderDto>("POST", "/api/render", body)
|
val r = call<RenderDto>("POST", "/api/render", body)
|
||||||
return if (r is ApiResult.Ok) ApiResult.Ok((r.value as RenderDto).html) else r as ApiResult<Nothing>
|
return if (r is ApiResult.Ok) ApiResult.Ok((r.value as RenderDto).html) else r as ApiResult<Nothing>
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
package com.labexposed.notas.data.remote
|
package com.labexposed.notas.data.remote
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
@@ -29,6 +33,8 @@ class SseClient(private val baseUrl: String, private val token: String, private
|
|||||||
try {
|
try {
|
||||||
streamOnce(onEvent)
|
streamOnce(onEvent)
|
||||||
backoff = 1_000L // stream terminou limpo (scope cancelado não chega aqui)
|
backoff = 1_000L // stream terminou limpo (scope cancelado não chega aqui)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// rede caiu / servidor fora: espera e tenta de novo
|
// rede caiu / servidor fora: espera e tenta de novo
|
||||||
}
|
}
|
||||||
@@ -37,20 +43,24 @@ class SseClient(private val baseUrl: String, private val token: String, private
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun streamOnce(onEvent: (SseEvent) -> Unit) {
|
private suspend fun streamOnce(onEvent: (SseEvent) -> Unit) {
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url(baseUrl.trimEnd('/') + "/api/events?session=" + sessionId)
|
.url(baseUrl.trimEnd('/') + "/api/events?session=" + sessionId)
|
||||||
.header("Authorization", "Bearer $token")
|
.header("Authorization", "Bearer $token")
|
||||||
.header("Accept", "text/event-stream")
|
.header("Accept", "text/event-stream")
|
||||||
.build()
|
.build()
|
||||||
http.newCall(request).execute().use { resp ->
|
val call = http.newCall(request)
|
||||||
|
currentCoroutineContext().job.invokeOnCompletion { runCatching { call.cancel() } }
|
||||||
|
call.execute().use { resp ->
|
||||||
if (!resp.isSuccessful) throw java.io.IOException("SSE HTTP ${resp.code}")
|
if (!resp.isSuccessful) throw java.io.IOException("SSE HTTP ${resp.code}")
|
||||||
val reader = resp.body?.source()?.inputStream()?.bufferedReader() ?: throw java.io.IOException("sem corpo")
|
val reader = resp.body?.source()?.inputStream()?.bufferedReader() ?: throw java.io.IOException("sem corpo")
|
||||||
var data = StringBuilder()
|
var data = StringBuilder()
|
||||||
while (true) {
|
while (true) {
|
||||||
|
currentCoroutineContext().ensureActive()
|
||||||
val line = reader.readLine() ?: break
|
val line = reader.readLine() ?: break
|
||||||
when {
|
when {
|
||||||
line.startsWith("data: ") -> data.append(line.removePrefix("data: "))
|
line.startsWith("data: ") -> data.append(line.removePrefix("data: "))
|
||||||
|
line.startsWith("data:") -> data.append(line.removePrefix("data:").trimStart())
|
||||||
line.isEmpty() && data.isNotEmpty() -> {
|
line.isEmpty() && data.isNotEmpty() -> {
|
||||||
val raw = data.toString()
|
val raw = data.toString()
|
||||||
data = StringBuilder()
|
data = StringBuilder()
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ class SyncEngine(
|
|||||||
SyncStatusStore.syncMutex.withLock {
|
SyncStatusStore.syncMutex.withLock {
|
||||||
try {
|
try {
|
||||||
runSync(trigger)
|
runSync(trigger)
|
||||||
|
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.e("SyncEngine", "fullSync($trigger) falhou", e)
|
android.util.Log.e("SyncEngine", "fullSync($trigger) falhou", e)
|
||||||
status.set { it.copy(phase = SyncPhase.Error, message = e.message ?: "erro interno") }
|
status.set { it.copy(phase = SyncPhase.Error, message = e.message ?: "erro interno") }
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class SyncWorker(context: Context, params: WorkerParameters) :
|
|||||||
)
|
)
|
||||||
return when (val outcome = engine.fullSync(trigger = inputData.getString(TRIGGER) ?: "auto")) {
|
return when (val outcome = engine.fullSync(trigger = inputData.getString(TRIGGER) ?: "auto")) {
|
||||||
is com.labexposed.notas.domain.SyncOutcome.NetworkDown -> Result.retry()
|
is com.labexposed.notas.domain.SyncOutcome.NetworkDown -> Result.retry()
|
||||||
is com.labexposed.notas.domain.SyncOutcome.Failed -> Result.retry()
|
is com.labexposed.notas.domain.SyncOutcome.Failed -> Result.failure()
|
||||||
is com.labexposed.notas.domain.SyncOutcome.Unauthorized -> Result.success()
|
is com.labexposed.notas.domain.SyncOutcome.Unauthorized -> Result.success()
|
||||||
else -> Result.success()
|
else -> Result.success()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import androidx.compose.material.icons.filled.Add
|
|||||||
import androidx.compose.material.icons.filled.CloudDone
|
import androidx.compose.material.icons.filled.CloudDone
|
||||||
import androidx.compose.material.icons.filled.CloudOff
|
import androidx.compose.material.icons.filled.CloudOff
|
||||||
import androidx.compose.material.icons.filled.CloudSync
|
import androidx.compose.material.icons.filled.CloudSync
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.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
|
||||||
@@ -195,7 +196,32 @@ fun NotesListScreen(
|
|||||||
val remoteChanged by vm.remoteChanged.collectAsState()
|
val remoteChanged by vm.remoteChanged.collectAsState()
|
||||||
var filterCat by remember { mutableStateOf<String?>(null) }
|
var filterCat by remember { mutableStateOf<String?>(null) }
|
||||||
var filterTag by remember { mutableStateOf<String?>(null) }
|
var filterTag by remember { mutableStateOf<String?>(null) }
|
||||||
|
var deleteTarget by remember { mutableStateOf<NoteEntity?>(null) }
|
||||||
val snackbar = remember { SnackbarHostState() }
|
val snackbar = remember { SnackbarHostState() }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
deleteTarget?.let { target ->
|
||||||
|
androidx.compose.material3.AlertDialog(
|
||||||
|
onDismissRequest = { deleteTarget = null },
|
||||||
|
icon = { Icon(Icons.Default.Delete, null) },
|
||||||
|
title = { Text("Excluir nota?") },
|
||||||
|
text = {
|
||||||
|
Text("“${target.title.ifBlank { "Sem título" }}” será removida deste aparelho " +
|
||||||
|
"e do servidor na próxima sincronização (o histórico dela fica nas revisões " +
|
||||||
|
"do servidor até a exclusão subir). Esta ação não pode ser desfeita pelo app.")
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
vm.deleteNote(target.id)
|
||||||
|
deleteTarget = null
|
||||||
|
scope.launch { snackbar.showSnackbar("Exclusão registrada — sincronizando") }
|
||||||
|
}) { Text("Excluir", color = MaterialTheme.colorScheme.error) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { deleteTarget = null }) { Text("Cancelar") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val catPaths = remember(cats) {
|
val catPaths = remember(cats) {
|
||||||
val byId = cats.associateBy { it.id }
|
val byId = cats.associateBy { it.id }
|
||||||
@@ -301,6 +327,7 @@ fun NotesListScreen(
|
|||||||
others = presence[note.id] ?: 0,
|
others = presence[note.id] ?: 0,
|
||||||
remotelyChanged = remoteChanged.contains(note.id),
|
remotelyChanged = remoteChanged.contains(note.id),
|
||||||
onClick = { onOpen(note.id) },
|
onClick = { onOpen(note.id) },
|
||||||
|
onDelete = { deleteTarget = note },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -316,6 +343,7 @@ private fun NoteCard(
|
|||||||
others: Int,
|
others: Int,
|
||||||
remotelyChanged: Boolean,
|
remotelyChanged: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Card(onClick = onClick, shape = RoundedCornerShape(18.dp), elevation = CardDefaults.cardElevation(2.dp)) {
|
Card(onClick = onClick, shape = RoundedCornerShape(18.dp), elevation = CardDefaults.cardElevation(2.dp)) {
|
||||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
@@ -337,6 +365,10 @@ private fun NoteCard(
|
|||||||
Text("👥$others", style = MaterialTheme.typography.labelSmall,
|
Text("👥$others", style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.primary)
|
color = MaterialTheme.colorScheme.primary)
|
||||||
}
|
}
|
||||||
|
IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) {
|
||||||
|
Icon(Icons.Default.Delete, "Excluir", Modifier.size(18.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.outline)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
ago(note.updatedAt) + if (note.tags.isNotBlank())
|
ago(note.updatedAt) + if (note.tags.isNotBlank())
|
||||||
@@ -399,6 +431,7 @@ fun EditorScreen(vm: NotasViewModel, noteId: String, onBack: () -> Unit) {
|
|||||||
var selectedCats by remember { mutableStateOf<List<String>>(emptyList()) }
|
var selectedCats by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||||
var savedAt by remember { mutableStateOf("") }
|
var savedAt by remember { mutableStateOf("") }
|
||||||
var mode by remember { mutableStateOf(EditorMode.Edit) }
|
var mode by remember { mutableStateOf(EditorMode.Edit) }
|
||||||
|
var confirmDelete by remember { mutableStateOf(false) }
|
||||||
val cats by vm.categories.collectAsState()
|
val cats by vm.categories.collectAsState()
|
||||||
val remoteChanged by vm.remoteChanged.collectAsState()
|
val remoteChanged by vm.remoteChanged.collectAsState()
|
||||||
val presence by vm.presence.collectAsState()
|
val presence by vm.presence.collectAsState()
|
||||||
@@ -406,6 +439,28 @@ fun EditorScreen(vm: NotasViewModel, noteId: String, onBack: () -> Unit) {
|
|||||||
val keyboard = LocalSoftwareKeyboardController.current
|
val keyboard = LocalSoftwareKeyboardController.current
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
|
|
||||||
|
if (confirmDelete) {
|
||||||
|
androidx.compose.material3.AlertDialog(
|
||||||
|
onDismissRequest = { confirmDelete = false },
|
||||||
|
icon = { Icon(Icons.Default.Delete, null) },
|
||||||
|
title = { Text("Excluir nota?") },
|
||||||
|
text = {
|
||||||
|
Text("“${title.text.ifBlank { "Sem título" }}” será removida deste aparelho e do " +
|
||||||
|
"servidor na próxima sincronização. Esta ação não pode ser desfeita pelo app.")
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
confirmDelete = false
|
||||||
|
vm.deleteNote(noteId)
|
||||||
|
onBack()
|
||||||
|
}) { Text("Excluir", color = MaterialTheme.colorScheme.error) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { confirmDelete = false }) { Text("Cancelar") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(noteId) {
|
LaunchedEffect(noteId) {
|
||||||
vm.noteById(noteId)?.let {
|
vm.noteById(noteId)?.let {
|
||||||
title = TextFieldValue(it.title)
|
title = TextFieldValue(it.title)
|
||||||
@@ -493,6 +548,9 @@ fun EditorScreen(vm: NotasViewModel, noteId: String, onBack: () -> Unit) {
|
|||||||
IconButton(onClick = { persist(); keyboard?.hide() }) {
|
IconButton(onClick = { persist(); keyboard?.hide() }) {
|
||||||
Icon(Icons.Default.CloudDone, "Salvar")
|
Icon(Icons.Default.CloudDone, "Salvar")
|
||||||
}
|
}
|
||||||
|
IconButton(onClick = { confirmDelete = true }) {
|
||||||
|
Icon(Icons.Default.Delete, "Excluir", tint = MaterialTheme.colorScheme.outline)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
Reference in new issue
Block a user