diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorScreen.kt index 8d18e28..9926986 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorScreen.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorScreen.kt @@ -1,6 +1,11 @@ package dev.dettmer.simplenotes.ui.editor +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -53,6 +58,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import dev.dettmer.simplenotes.R import dev.dettmer.simplenotes.models.NoteType +import dev.dettmer.simplenotes.ui.editor.components.CheckedItemsSeparator import dev.dettmer.simplenotes.ui.editor.components.ChecklistItemRow import dev.dettmer.simplenotes.ui.main.components.DeleteConfirmationDialog import kotlinx.coroutines.delay @@ -318,7 +324,12 @@ private fun ChecklistEditor( scope = scope, onMove = onMove ) - + + // 🆕 v1.8.0 (IMPL_017): Separator-Position berechnen + val uncheckedCount = items.count { !it.isChecked } + val checkedCount = items.count { it.isChecked } + val showSeparator = uncheckedCount > 0 && checkedCount > 0 + Column(modifier = modifier) { LazyColumn( state = listState, @@ -330,48 +341,61 @@ private fun ChecklistEditor( items = items, key = { _, item -> item.id } ) { index, item -> + // 🆕 v1.8.0 (IMPL_017): Separator vor dem ersten Checked-Item + if (showSeparator && index == uncheckedCount) { + CheckedItemsSeparator(checkedCount = checkedCount) + } + val isDragging = dragDropState.draggingItemIndex == index val elevation by animateDpAsState( targetValue = if (isDragging) 8.dp else 0.dp, label = "elevation" ) - + val shouldFocus = item.id == focusNewItemId - + // v1.5.0: Clear focus request after handling LaunchedEffect(shouldFocus) { if (shouldFocus) { onFocusHandled() } } - - ChecklistItemRow( - item = item, - onTextChange = { onTextChange(item.id, it) }, - onCheckedChange = { onCheckedChange(item.id, it) }, - onDelete = { onDelete(item.id) }, - onAddNewItem = { onAddNewItemAfter(item.id) }, - requestFocus = shouldFocus, - isDragging = isDragging, // 🆕 v1.8.0: IMPL_023 - Drag state übergeben - isAnyItemDragging = dragDropState.draggingItemIndex != null, // 🆕 v1.8.0: IMPL_023 - Gradient während Drag ausblenden - dragModifier = Modifier.dragContainer(dragDropState, index), // 🆕 v1.8.0: IMPL_023 - Drag nur auf Handle - modifier = Modifier - .offset { - IntOffset( - 0, - if (isDragging) dragDropState.draggingItemOffset.roundToInt() else 0 + + // 🆕 v1.8.0 (IMPL_017): AnimatedVisibility für sanfte Übergänge + AnimatedVisibility( + visible = true, + enter = fadeIn() + slideInVertically(), + exit = fadeOut() + slideOutVertically() + ) { + ChecklistItemRow( + item = item, + onTextChange = { onTextChange(item.id, it) }, + onCheckedChange = { onCheckedChange(item.id, it) }, + onDelete = { onDelete(item.id) }, + onAddNewItem = { onAddNewItemAfter(item.id) }, + requestFocus = shouldFocus, + isDragging = isDragging, // 🆕 v1.8.0: IMPL_023 - Drag state übergeben + isAnyItemDragging = dragDropState.draggingItemIndex != null, // 🆕 v1.8.0: IMPL_023 - Gradient während Drag ausblenden + dragModifier = Modifier.dragContainer(dragDropState, index), // 🆕 v1.8.0: IMPL_023 - Drag nur auf Handle + modifier = Modifier + .animateItem() // 🆕 v1.8.0 (IMPL_017): LazyColumn Item-Animation + .offset { + IntOffset( + 0, + if (isDragging) dragDropState.draggingItemOffset.roundToInt() else 0 + ) + } + .zIndex(if (isDragging) 10f else 0f) // 🆕 v1.8.0: IMPL_023 - Gedraggtes Item liegt über anderen + .shadow(elevation, shape = RoundedCornerShape(8.dp)) + .background( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(8.dp) ) - } - .zIndex(if (isDragging) 10f else 0f) // 🆕 v1.8.0: IMPL_023 - Gedraggtes Item liegt über anderen - .shadow(elevation, shape = RoundedCornerShape(8.dp)) - .background( - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(8.dp) - ) - ) + ) + } } } - + // Add Item Button TextButton( onClick = onAddItemAtEnd, diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorViewModel.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorViewModel.kt index 93e4f2b..b15458c 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorViewModel.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorViewModel.kt @@ -104,7 +104,7 @@ class NoteEditorViewModel( } if (note.noteType == NoteType.CHECKLIST) { - val items = note.checklistItems?.sortedBy { it.order }?.map { + val items = note.checklistItems?.sortedBy { it.order }?.map { ChecklistItemState( id = it.id, text = it.text, @@ -112,7 +112,8 @@ class NoteEditorViewModel( order = it.order ) } ?: emptyList() - _checklistItems.value = items + // 🆕 v1.8.0 (IMPL_017): Sortierung sicherstellen (falls alte Daten unsortiert sind) + _checklistItems.value = sortChecklistItems(items) } } } else { @@ -163,11 +164,26 @@ class NoteEditorViewModel( } } + /** + * 🆕 v1.8.0 (IMPL_017): Sortiert Checklist-Items mit Unchecked oben, Checked unten. + * Stabile Sortierung: Relative Reihenfolge innerhalb jeder Gruppe bleibt erhalten. + */ + private fun sortChecklistItems(items: List): List { + val unchecked = items.filter { !it.isChecked } + val checked = items.filter { it.isChecked } + + return (unchecked + checked).mapIndexed { index, item -> + item.copy(order = index) + } + } + fun updateChecklistItemChecked(itemId: String, isChecked: Boolean) { _checklistItems.update { items -> - items.map { item -> + val updatedItems = items.map { item -> if (item.id == itemId) item.copy(isChecked = isChecked) else item } + // 🆕 v1.8.0 (IMPL_017): Nach Toggle sortieren + sortChecklistItems(updatedItems) } } @@ -208,6 +224,15 @@ class NoteEditorViewModel( fun moveChecklistItem(fromIndex: Int, toIndex: Int) { _checklistItems.update { items -> + val fromItem = items.getOrNull(fromIndex) ?: return@update items + val toItem = items.getOrNull(toIndex) ?: return@update items + + // 🆕 v1.8.0 (IMPL_017): Drag nur innerhalb der gleichen Gruppe erlauben + // (checked ↔ checked, unchecked ↔ unchecked) + if (fromItem.isChecked != toItem.isChecked) { + return@update items // Kein Move über Gruppen-Grenze + } + val mutableList = items.toMutableList() val item = mutableList.removeAt(fromIndex) mutableList.add(toIndex, item) diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/components/CheckedItemsSeparator.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/components/CheckedItemsSeparator.kt new file mode 100644 index 0000000..1b8e1c1 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/components/CheckedItemsSeparator.kt @@ -0,0 +1,54 @@ +package dev.dettmer.simplenotes.ui.editor.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.unit.dp +import dev.dettmer.simplenotes.R + +/** + * 🆕 v1.8.0 (IMPL_017): Visueller Separator zwischen unchecked und checked Items + * + * Zeigt eine dezente Linie mit Anzahl der erledigten Items: + * ── 3 completed ── + */ +@Composable +fun CheckedItemsSeparator( + checkedCount: Int, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + HorizontalDivider( + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.outlineVariant + ) + + Text( + text = pluralStringResource( + R.plurals.checked_items_count, + checkedCount, + checkedCount + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + modifier = Modifier.padding(horizontal = 12.dp) + ) + + HorizontalDivider( + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.outlineVariant + ) + } +} diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 893bf41..af204a4 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -505,4 +505,10 @@ %d Notiz synchronisiert %d Notizen synchronisiert + + + + %d erledigt + %d erledigt + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 6a6bf97..8927ebe 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -513,6 +513,12 @@ %d notes synced + + + %d completed + %d completed + + diff --git a/android/app/src/test/java/dev/dettmer/simplenotes/ui/editor/ChecklistSortingTest.kt b/android/app/src/test/java/dev/dettmer/simplenotes/ui/editor/ChecklistSortingTest.kt new file mode 100644 index 0000000..768586f --- /dev/null +++ b/android/app/src/test/java/dev/dettmer/simplenotes/ui/editor/ChecklistSortingTest.kt @@ -0,0 +1,177 @@ +package dev.dettmer.simplenotes.ui.editor + +import org.junit.Assert.* +import org.junit.Test + +/** + * 🆕 v1.8.0 (IMPL_017): Unit Tests für Checklisten-Sortierung + * + * Validiert die Auto-Sort Funktionalität: + * - Unchecked items erscheinen vor checked items + * - Relative Reihenfolge innerhalb jeder Gruppe bleibt erhalten (stabile Sortierung) + * - Order-Werte werden korrekt neu zugewiesen + */ +class ChecklistSortingTest { + + /** + * Helper function to create a test ChecklistItemState + */ + private fun item(id: String, checked: Boolean, order: Int): ChecklistItemState { + return ChecklistItemState( + id = id, + text = "Item $id", + isChecked = checked, + order = order + ) + } + + /** + * Simulates the sortChecklistItems() function from NoteEditorViewModel + * (Since it's private, we test the logic here) + */ + private fun sortChecklistItems(items: List): List { + val unchecked = items.filter { !it.isChecked } + val checked = items.filter { it.isChecked } + + return (unchecked + checked).mapIndexed { index, item -> + item.copy(order = index) + } + } + + @Test + fun `unchecked items appear before checked items`() { + val items = listOf( + item("a", checked = true, order = 0), + item("b", checked = false, order = 1), + item("c", checked = true, order = 2), + item("d", checked = false, order = 3) + ) + + val sorted = sortChecklistItems(items) + + assertFalse("First item should be unchecked", sorted[0].isChecked) // b + assertFalse("Second item should be unchecked", sorted[1].isChecked) // d + assertTrue("Third item should be checked", sorted[2].isChecked) // a + assertTrue("Fourth item should be checked", sorted[3].isChecked) // c + } + + @Test + fun `relative order within groups is preserved (stable sort)`() { + val items = listOf( + item("first-checked", checked = true, order = 0), + item("first-unchecked", checked = false, order = 1), + item("second-checked", checked = true, order = 2), + item("second-unchecked",checked = false, order = 3) + ) + + val sorted = sortChecklistItems(items) + + assertEquals("first-unchecked", sorted[0].id) + assertEquals("second-unchecked", sorted[1].id) + assertEquals("first-checked", sorted[2].id) + assertEquals("second-checked", sorted[3].id) + } + + @Test + fun `all unchecked - no change needed`() { + val items = listOf( + item("a", checked = false, order = 0), + item("b", checked = false, order = 1) + ) + + val sorted = sortChecklistItems(items) + + assertEquals("a", sorted[0].id) + assertEquals("b", sorted[1].id) + } + + @Test + fun `all checked - no change needed`() { + val items = listOf( + item("a", checked = true, order = 0), + item("b", checked = true, order = 1) + ) + + val sorted = sortChecklistItems(items) + + assertEquals("a", sorted[0].id) + assertEquals("b", sorted[1].id) + } + + @Test + fun `order values are reassigned after sort`() { + val items = listOf( + item("a", checked = true, order = 0), + item("b", checked = false, order = 1) + ) + + val sorted = sortChecklistItems(items) + + assertEquals(0, sorted[0].order) // b → order 0 + assertEquals(1, sorted[1].order) // a → order 1 + } + + @Test + fun `empty list returns empty list`() { + val items = emptyList() + val sorted = sortChecklistItems(items) + assertTrue("Empty list should remain empty", sorted.isEmpty()) + } + + @Test + fun `single item list returns unchanged`() { + val items = listOf(item("a", checked = false, order = 0)) + val sorted = sortChecklistItems(items) + + assertEquals(1, sorted.size) + assertEquals("a", sorted[0].id) + assertEquals(0, sorted[0].order) + } + + @Test + fun `mixed list with multiple items maintains correct grouping`() { + val items = listOf( + item("1", checked = false, order = 0), + item("2", checked = true, order = 1), + item("3", checked = false, order = 2), + item("4", checked = true, order = 3), + item("5", checked = false, order = 4) + ) + + val sorted = sortChecklistItems(items) + + // First 3 should be unchecked + assertFalse(sorted[0].isChecked) + assertFalse(sorted[1].isChecked) + assertFalse(sorted[2].isChecked) + + // Last 2 should be checked + assertTrue(sorted[3].isChecked) + assertTrue(sorted[4].isChecked) + + // Verify order within unchecked group (1, 3, 5) + assertEquals("1", sorted[0].id) + assertEquals("3", sorted[1].id) + assertEquals("5", sorted[2].id) + + // Verify order within checked group (2, 4) + assertEquals("2", sorted[3].id) + assertEquals("4", sorted[4].id) + } + + @Test + fun `orders are sequential after sorting`() { + val items = listOf( + item("a", checked = true, order = 10), + item("b", checked = false, order = 5), + item("c", checked = false, order = 20) + ) + + val sorted = sortChecklistItems(items) + + // Orders should be 0, 1, 2 regardless of input + assertEquals(0, sorted[0].order) + assertEquals(1, sorted[1].order) + assertEquals(2, sorted[2].order) + } +}