package com.labexposed.notas.ui 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 import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.CircularProgressIndicator import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.OutlinedTextField 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 import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.compose.foundation.isSystemInDarkTheme import com.labexposed.notas.data.local.NoteEntity import com.labexposed.notas.domain.SyncPhase import com.labexposed.notas.domain.SyncUiState import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch private val AUTOSAVE_IDLE_MS = 10_000L private fun ago(ms: Long): String { val d = (System.currentTimeMillis() - ms) / 1000 return when { d < 45 -> "agora" d < 3600 -> "há ${d / 60} min" d < 86400 -> "há ${d / 3600} h" else -> java.text.DateFormat.getDateInstance(java.text.DateFormat.SHORT).format(java.util.Date(ms)) } } private fun extOf(format: String) = when (format) { "md" -> ".md"; "html" -> ".html"; else -> ".txt" } /* ================================================================ Servidor */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen(vm: NotasViewModel, onDone: () -> Unit) { val settings by vm.settings.collectAsState() var url by remember { mutableStateOf(TextFieldValue(settings.serverUrl)) } var token by remember { mutableStateOf(TextFieldValue(settings.token)) } var author by remember { mutableStateOf(TextFieldValue(settings.author)) } var feedback by remember { mutableStateOf("") } val scope = rememberCoroutineScope() val events by vm.events.collectAsState() LaunchedEffect(events) { when (val e = events) { is ListEvent.ConnectionOk -> feedback = "✓ Conectado — ${e.notes} nota(s) no servidor." is ListEvent.ConnectionFail -> feedback = "✗ ${e.message}" is ListEvent.RemoteChange -> {} null -> {} } if (events != null) vm.consumeEvent() } Scaffold(topBar = { MediumTopAppBar(title = { Text("Servidor") }) }) { pad -> Column( modifier = Modifier.padding(pad).padding(horizontal = 20.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(14.dp), ) { Text( "Conecte o app à sua instância do notas-online. Tudo funciona offline; " + "as alterações sincronizam quando houver rede.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) OutlinedTextField( value = url, onValueChange = { url = it }, label = { Text("URL da API") }, placeholder = { Text("http://192.168.0.10:8080") }, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(14.dp), ) OutlinedTextField( value = token, onValueChange = { token = it }, label = { Text("Token de acesso (NOTES_TOKEN)") }, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(14.dp), ) OutlinedTextField( value = author, onValueChange = { author = it }, label = { Text("Autor das revisões") }, singleLine = true, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(14.dp), ) if (feedback.isNotEmpty()) { Surface( color = if (feedback.startsWith("✓")) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.errorContainer, shape = RoundedCornerShape(12.dp), ) { Text(feedback, Modifier.padding(12.dp), style = MaterialTheme.typography.bodyMedium) } } Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { TextButton(onClick = { 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(ServerSettings(url.text, token.text, author.text)) onDone() }, shape = RoundedCornerShape(14.dp), ) { Text("Salvar e continuar") } } } } } /* ==================================================================== Lista */ @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun NotesListScreen( vm: NotasViewModel, onOpen: (String) -> Unit, onNew: () -> Unit, onSettings: () -> Unit, ) { val notes by vm.notes.collectAsState() val sync by vm.syncState.collectAsState() val cats by vm.categories.collectAsState() val presence by vm.presence.collectAsState() val remoteChanged by vm.remoteChanged.collectAsState() var filterCat by remember { mutableStateOf(null) } var filterTag by remember { mutableStateOf(null) } var query by remember { mutableStateOf("") } var deleteTarget by remember { mutableStateOf(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 } fun path(id: String): String { val parts = mutableListOf() var cur = byId[id] var guard = 0 while (cur != null && guard++ < 10) { parts.add(0, cur.name) cur = byId[cur.parent] } return parts.joinToString("/") } cats.associate { it.id to path(it.id) } } val allTags = remember(notes) { notes.flatMap { it.tags.split(",").filter { t -> t.isNotBlank() } }.distinct().sorted() } 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) { snackbar.showSnackbar("“${(events as ListEvent.RemoteChange).title}” foi atualizada pelo servidor") vm.consumeEvent() } } Scaffold( snackbarHost = { SnackbarHost(snackbar) }, topBar = { MediumTopAppBar( title = { Text("Notas") }, actions = { IconButton(onClick = { vm.syncNow() }) { if (sync.phase == SyncPhase.Syncing) { CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) } else { Icon(Icons.Default.CloudSync, contentDescription = "Sincronizar agora") } } IconButton(onClick = onSettings) { Icon(Icons.Default.Description, contentDescription = "Servidor") } }, colors = TopAppBarDefaults.mediumTopAppBarColors( containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), ), ) }, floatingActionButton = { ExtendedFloatingActionButton( onClick = onNew, icon = { Icon(Icons.Default.Add, null) }, text = { Text("Nova") }, ) }, ) { pad -> 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), ) { 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(), ) } } } } } /** 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( note: NoteEntity, catPaths: Map, others: Int, remotelyChanged: Boolean, onClick: () -> Unit, onDelete: () -> Unit, modifier: Modifier = Modifier, ) { 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), style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f), maxLines = 1, ) when { 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)) 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( ago(note.updatedAt) + if (note.tags.isNotBlank()) " · " + note.tags.split(",").filter { it.isNotBlank() }.joinToString(" ") { "#$it" } else "", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, ) val catList = note.categories.split(",").filter { it.isNotBlank() } if (catList.isNotEmpty() || remotelyChanged || note.conflictNote.isNotBlank()) { 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) }, leadingIcon = { Icon(Icons.Default.Folder, contentDescription = null, Modifier.size(14.dp)) }, ) } if (remotelyChanged) WarnChip("mudou remotamente") if (note.conflictNote.isNotBlank()) WarnChip("conflito resolvido") } } } } } @Composable private fun WarnChip(text: String) { Surface(color = MaterialTheme.colorScheme.tertiaryContainer, shape = RoundedCornerShape(50)) { Row(Modifier.padding(horizontal = 10.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { Icon(Icons.Default.Warning, null, Modifier.size(12.dp)) Text(text, style = MaterialTheme.typography.labelSmall) } } } @Composable private fun StatusDot(color: Color, desc: String) { Box( Modifier.size(12.dp), contentAlignment = Alignment.Center, ) { Surface(color = color, shape = androidx.compose.foundation.shape.CircleShape, modifier = Modifier.size(10.dp)) { Text("", Modifier.size(10.dp)) } } // descrição para acessibilidade via contentDescription do item pai não é crítica aqui } /* ================================================================== Editor */ private enum class EditorMode { Edit, Wysiwyg, Preview } @OptIn(ExperimentalMaterial3Api::class) @Composable fun EditorScreen(vm: NotasViewModel, noteId: String, onBack: () -> Unit) { var loaded by remember { mutableStateOf(false) } var title by remember { mutableStateOf(TextFieldValue("")) } var format by remember { mutableStateOf("md") } var body by remember { mutableStateOf(TextFieldValue("")) } var selectedCats by remember { mutableStateOf>(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() val controller = remember { WysiwygController() } 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) format = it.format body = TextFieldValue(it.content) selectedCats = it.categories.split(",").filter { c -> c.isNotBlank() } } loaded = true } LaunchedEffect(noteId, loaded) { vm.openNoteId = noteId } androidx.compose.runtime.DisposableEffect(Unit) { onDispose { vm.openNoteId = null } } // SSE: nota limpa aberta mudou no servidor → atualiza o corpo ao vivo val liveRefresh by vm.editorRefresh.collectAsState() LaunchedEffect(liveRefresh) { val r = liveRefresh ?: return@LaunchedEffect if (r.first == noteId) { body = TextFieldValue(r.second) vm.editorRefresh.value = null } } fun persist() { if (!loaded) return vm.saveNote(noteId, title.text, format, selectedCats, body.text) savedAt = java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT).format(java.util.Date()) } // WYSIWYG: ponte JS avisa mudança → Kotlin busca o markdown (debounce) var changeTick by remember { mutableStateOf(0) } LaunchedEffect(controller) { controller.onChanged = { changeTick++ } } LaunchedEffect(changeTick) { if (changeTick == 0) return@LaunchedEffect delay(900) controller.requestMd { md -> if (md != body.text) { body = TextFieldValue(md) persist() } } } // autosave por ociosidade no modo texto LaunchedEffect(title, body, loaded) { if (!loaded || mode != EditorMode.Edit) return@LaunchedEffect delay(AUTOSAVE_IDLE_MS) persist() } // ao entrar em WYSIWYG/Preview, (re)renderiza o html LaunchedEffect(mode, loaded) { if (!loaded || mode == EditorMode.Edit) return@LaunchedEffect if (format == "html") controller.setHtml(body.text) else vm.renderMarkdown(body.text) { html -> controller.setHtml(html) } } Scaffold( topBar = { androidx.compose.material3.TopAppBar( navigationIcon = { IconButton(onClick = { persist(); onBack() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Voltar") } }, title = { OutlinedTextField( value = title, onValueChange = { title = it; }, placeholder = { Text("Título") }, singleLine = true, colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = Color.Transparent, unfocusedBorderColor = Color.Transparent), modifier = Modifier.width(240.dp), ) }, actions = { 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, color = MaterialTheme.colorScheme.onSurfaceVariant, ) IconButton(onClick = { persist(); keyboard?.hide() }) { Icon(Icons.Default.CloudDone, "Salvar") } IconButton(onClick = { confirmDelete = true }) { Icon(Icons.Default.Delete, "Excluir", tint = MaterialTheme.colorScheme.outline) } }, ) }, ) { pad -> Column(modifier = Modifier.padding(pad).fillMaxSize()) { if (remoteChanged.contains(noteId)) { Surface(color = MaterialTheme.colorScheme.tertiaryContainer, shape = RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp)) { Row(Modifier.padding(horizontal = 14.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Text("Mudou no servidor.", style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f)) TextButton(onClick = { vm.reloadFromServer(noteId) { n -> n?.let { body = TextFieldValue(it.content) } } }) { Text("Recarregar") } TextButton(onClick = { vm.dismissRemoteChange(noteId) }) { Text("Manter") } } } } // seletor de modo + formato Row( Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, ) { ModeChip(mode == EditorMode.Edit, Icons.Default.Edit, "Editar") { mode = EditorMode.Edit } if (format != "txt") { ModeChip(mode == EditorMode.Wysiwyg, Icons.Default.Description, "WYSIWYG") { mode = EditorMode.Wysiwyg } ModeChip(mode == EditorMode.Preview, Icons.Default.Visibility, "Preview") { mode = EditorMode.Preview } } Spacer(Modifier.weight(1f)) listOf("txt", "md", "html").forEach { f -> FilterChip(selected = format == f, onClick = { format = f }, label = { Text(f.uppercase(), style = MaterialTheme.typography.labelSmall) }) } } if (cats.isNotEmpty() && mode == EditorMode.Edit) { Row( Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()) .padding(horizontal = 12.dp, vertical = 2.dp), horizontalArrangement = Arrangement.spacedBy(6.dp), ) { cats.take(14).forEach { c -> FilterChip( selected = selectedCats.contains(c.id), onClick = { selectedCats = if (selectedCats.contains(c.id)) selectedCats - c.id else selectedCats + c.id }, label = { Text(c.name, style = MaterialTheme.typography.labelSmall) }, ) } } } when (mode) { EditorMode.Edit -> { OutlinedTextField( value = body, onValueChange = { body = it }, placeholder = { Text("Escreva… #tags viram tags no servidor") }, modifier = Modifier.fillMaxWidth().weight(1f).padding(horizontal = 12.dp), shape = RoundedCornerShape(16.dp), textStyle = TextStyle(fontFamily = FontFamily.Monospace, fontSize = MaterialTheme.typography.bodyLarge.fontSize), ) MdToolbar( enabled = format != "txt", onCmd = { cmd -> body = when (cmd) { "bold" -> wrap(body, "**", "**", "negrito") "italic" -> wrap(body, "*", "*", "itálico") "strike" -> wrap(body, "~~", "~~", "tachado") "code" -> wrap(body, "`", "`", "código") "h1" -> prefix(body, "# ") "h2" -> prefix(body, "## ") "ul" -> prefix(body, "- ") "quote" -> prefix(body, "> ") "link" -> wrap(body, "[", "](https://)", "link") "block" -> wrap(body, "\n```\n", "\n```\n", "código em bloco") else -> body } }, ) } EditorMode.Wysiwyg -> { EditorWebView(controller, dark, Modifier.fillMaxWidth().weight(1f)) WysiwygToolbar(controller, format) } EditorMode.Preview -> { EditorWebView(controller, dark, Modifier.fillMaxWidth().weight(1f)) } } } } } @Composable private fun ModeChip(selected: Boolean, icon: ImageVector, label: String, onClick: () -> Unit) { FilterChip(selected = selected, onClick = onClick, leadingIcon = { Icon(icon, null, Modifier.size(16.dp)) }, label = { Text(label, style = MaterialTheme.typography.labelMedium) }) } @Composable private fun MdToolbar(enabled: Boolean, onCmd: (String) -> Unit) { val items = listOf( "B" to "bold", "I" to "italic", "S" to "strike", "H1" to "h1", "H2" to "h2", "•" to "ul", "❝" to "quote", "🔗" to "link", "" to "code", "{ }" to "block", ) Surface( tonalElevation = 3.dp, modifier = Modifier.fillMaxWidth().padding(top = 4.dp), ) { Row( Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 8.dp, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(2.dp), ) { items.forEach { (label, cmd) -> TextButton( onClick = { if (enabled) onCmd(cmd) }, enabled = enabled, contentPadding = androidx.compose.foundation.layout.PaddingValues(8.dp, 4.dp), ) { Text(label, fontWeight = FontWeight.Bold) } } } } } @Composable private fun WysiwygToolbar(controller: WysiwygController, format: String) { Surface(tonalElevation = 3.dp, modifier = Modifier.fillMaxWidth()) { Row( Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 8.dp, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(2.dp), ) { val cmds: List Unit>> = if (format == "html") listOf( "B" to { controller.exec("bold") }, "I" to { controller.exec("italic") }, "S" to { controller.exec("strikeThrough") }, "H1" to { controller.exec("formatBlock", "h1") }, "H2" to { controller.exec("formatBlock", "h2") }, "•" to { controller.exec("insertUnorderedList") }, "1." to { controller.exec("insertOrderedList") }, "❝" to { controller.exec("formatBlock", "blockquote") }, "🔗" to { controller.exec("createLink", "https://") }, "" to { controller.exec("insertHTML", "código") }, ) else listOf( "B" to { controller.exec("bold") }, "I" to { controller.exec("italic") }, "S" to { controller.exec("strikeThrough") }, "H1" to { controller.exec("formatBlock", "h1") }, "H2" to { controller.exec("formatBlock", "h2") }, "•" to { controller.exec("insertUnorderedList") }, "1." to { controller.exec("insertOrderedList") }, "❝" to { controller.exec("formatBlock", "blockquote") }, "🔗" to { controller.exec("createLink", "https://") }, "" to { controller.exec("insertHTML", "código") }, "{ }" to { controller.exec("insertHTML", "
bloco
") }, ) cmds.forEach { (label, act) -> TextButton(onClick = act, contentPadding = androidx.compose.foundation.layout.PaddingValues(8.dp, 4.dp)) { Text(label, fontWeight = FontWeight.Bold) } } } } } // ---- helpers de markdown no TextField ---- private fun wrap(v: TextFieldValue, before: String, after: String, placeholder: String): TextFieldValue { val sel = v.selection val text = if (sel.length > 0) v.text.substring(sel.start, sel.end) else placeholder val out = v.text.substring(0, sel.start) + before + text + after + v.text.substring(sel.end) val start = sel.start + before.length return TextFieldValue(out, TextRange(start, start + text.length)) } private fun prefix(v: TextFieldValue, prefixStr: String): TextFieldValue { val start = v.text.lastIndexOf('\n', (v.selection.start - 1).coerceAtLeast(0)) + 1 var end = v.text.indexOf('\n', v.selection.end) if (end < 0) end = v.text.length val block = v.text.substring(start, end) val newBlock = block.split("\n").joinToString("\n") { line -> if (line.startsWith(prefixStr)) line.removePrefix(prefixStr) else prefixStr + line } val out = v.text.substring(0, start) + newBlock + v.text.substring(end) val caret = start + newBlock.length return TextFieldValue(out, TextRange(caret, caret)) }