This commit is contained in:
team3
2026-09-22 13:14:06 +02:00
parent a62b587340
commit 2472e89b9c
15 changed files with 461 additions and 28 deletions

View File

@@ -6,7 +6,11 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -18,9 +22,13 @@ 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.Description
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.automirrored.filled.FormatIndentDecrease
import androidx.compose.material.icons.automirrored.filled.FormatIndentIncrease
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.FormatBold
import androidx.compose.material.icons.filled.FormatListNumbered
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FloatingActionButton
@@ -41,9 +49,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
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.TextFieldValue
import androidx.compose.ui.unit.dp
import de.marha.planer.data.Editor
import de.marha.planer.data.Markdown
import de.marha.planer.data.Store
import de.marha.planer.data.TreeNode
@@ -155,8 +168,12 @@ private fun TreeRow(row: Row, onClick: () -> Unit, onLongClick: () -> Unit) {
.padding(start = (12 + row.depth * 20).dp, end = 12.dp, top = 10.dp, bottom = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val icon = if (!row.isFolder) Icons.Filled.Description else if (row.open) Icons.Filled.FolderOpen else Icons.Filled.Folder
Icon(icon, contentDescription = null, tint = Muted, modifier = Modifier.padding(end = 10.dp))
if (row.isFolder) {
val icon = if (row.open) Icons.Filled.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight
Icon(icon, contentDescription = if (row.open) "aufgeklappt" else "zugeklappt", tint = Muted, modifier = Modifier.padding(end = 10.dp))
} else {
Spacer(Modifier.width(34.dp))
}
Text(row.name, style = if (row.isFolder) MaterialTheme.typography.titleMedium else MaterialTheme.typography.bodyLarge)
}
}
@@ -167,11 +184,11 @@ fun NoteView(store: Store, id: String, onClose: () -> Unit) {
store.version.intValue
val note = store.note(id)
var editing by rememberSaveable { mutableStateOf(false) }
var text by rememberSaveable { mutableStateOf(note?.content ?: "") }
var value by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue(note?.content ?: "")) }
val focus = remember { FocusRequester() }
fun flush() {
if (editing) store.writeNote(id, text)
if (editing) store.writeNote(id, value.text)
}
fun stopEdit() {
flush()
@@ -184,7 +201,8 @@ fun NoteView(store: Store, id: String, onClose: () -> Unit) {
LaunchedEffect(editing) {
if (editing) {
store.lockedNote = id
text = store.note(id)?.content ?: ""
val content = store.note(id)?.content ?: ""
value = TextFieldValue(content, TextRange(content.length))
focus.requestFocus()
while (true) {
delay(AUTOSAVE_MS)
@@ -200,9 +218,10 @@ fun NoteView(store: Store, id: String, onClose: () -> Unit) {
if (editing) Text("Fertig", color = Orange, modifier = Modifier.clickable { stopEdit() }.padding(12.dp))
}
if (editing) {
EditorBar { change -> value = change(value) }
BasicTextField(
value = text,
onValueChange = { text = it },
value = value,
onValueChange = { value = Editor.afterInput(value, it) },
textStyle = TextStyle(color = MaterialTheme.colorScheme.onBackground, fontFamily = FontFamily.Monospace, fontSize = MaterialTheme.typography.bodyMedium.fontSize),
cursorBrush = SolidColor(Orange),
modifier = Modifier.fillMaxSize().padding(16.dp).focusRequester(focus),
@@ -215,3 +234,26 @@ fun NoteView(store: Store, id: String, onClose: () -> Unit) {
}
}
}
// Werkzeugleiste über dem Editor: Überschriften, Fett, Listen, Ein- und Ausrücken
@Composable
private fun EditorBar(apply: ((TextFieldValue) -> TextFieldValue) -> Unit) {
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically) {
for (level in 1..6) {
Text(
"H$level", color = Muted, fontWeight = FontWeight.Bold,
modifier = Modifier.clickable { apply { Editor.heading(it, level) } }.padding(horizontal = 5.dp, vertical = 12.dp),
)
}
BarIcon(Icons.Filled.FormatBold, "Fett") { apply(Editor::bold) }
BarIcon(Icons.AutoMirrored.Filled.FormatListBulleted, "Aufzählung") { apply { Editor.list(it, false) } }
BarIcon(Icons.Filled.FormatListNumbered, "Nummerierung") { apply { Editor.list(it, true) } }
BarIcon(Icons.AutoMirrored.Filled.FormatIndentDecrease, "Ausrücken") { apply { Editor.indent(it, -1) } }
BarIcon(Icons.AutoMirrored.Filled.FormatIndentIncrease, "Einrücken") { apply { Editor.indent(it, 1) } }
}
}
@Composable
private fun BarIcon(icon: ImageVector, label: String, onClick: () -> Unit) {
IconButton(onClick = onClick, modifier = Modifier.size(40.dp)) { Icon(icon, contentDescription = label, tint = Muted) }
}

View File

@@ -0,0 +1,68 @@
package de.marha.planer
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import de.marha.planer.data.Editor
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class EditorTest {
private fun v(text: String, cursor: Int = text.length, end: Int = cursor) = TextFieldValue(text, TextRange(cursor, end))
@Test fun continueList() {
assertEquals("- ", Editor.continueList("- eins"))
assertEquals(" * ", Editor.continueList(" * eins"))
assertEquals("- [ ] ", Editor.continueList("- [x] fertig"))
assertEquals("4. ", Editor.continueList("3. drei"))
assertEquals("", Editor.continueList("- "))
assertEquals("", Editor.continueList("2. "))
assertNull(Editor.continueList("nur Text"))
assertNull(Editor.continueList("---"))
}
@Test fun enterContinuesAndEndsList() {
val next = Editor.afterInput(v("- a"), v("- a\n"))
assertEquals("- a\n- ", next.text)
assertEquals(6, next.selection.start)
val ended = Editor.afterInput(v("- a\n- "), v("- a\n- \n"))
assertEquals("- a\n", ended.text)
assertEquals(4, ended.selection.start)
// Umbruch mitten im Text: der Rest wandert hinter den neuen Marker
val mid = Editor.afterInput(v("1. ab", 4), v("1. a\nb", 5))
assertEquals("1. a\n2. b", mid.text)
assertEquals(8, mid.selection.start)
assertEquals("x\n", Editor.afterInput(v("x"), v("x\n")).text)
}
@Test fun headings() {
val h2 = Editor.heading(v("abc"), 2)
assertEquals("## abc", h2.text)
assertEquals(6, h2.selection.start)
assertEquals("abc", Editor.heading(h2, 2).text)
assertEquals("# abc", Editor.heading(h2, 1).text)
assertEquals("x\n### y\nz", Editor.heading(v("x\ny\nz", 3), 3).text)
}
@Test fun lists() {
val ul = Editor.list(v("abc", 1), false)
assertEquals("- abc", ul.text)
assertEquals(3, ul.selection.start)
assertEquals("abc", Editor.list(ul, false).text)
assertEquals("1. abc", Editor.list(ul, true).text)
assertEquals("1. x\n2. y", Editor.list(v("1. x\ny"), true).text)
assertEquals(" t", Editor.list(v(" - [ ] t"), false).text)
}
@Test fun indentAndBold() {
assertEquals(" - a", Editor.indent(v("- a"), 1).text)
assertEquals("- a", Editor.indent(v(" - a"), -1).text)
assertEquals("- a", Editor.indent(v("- a"), -1).text)
val b = Editor.bold(v("abc", 0, 3))
assertEquals("**abc**", b.text)
assertEquals(TextRange(2, 5), b.selection)
val empty = Editor.bold(v("ab", 1))
assertEquals("a****b", empty.text)
assertEquals(3, empty.selection.start)
}
}