feat(v1.8.0): IMPL_017 Checklist Separator & Sorting - Unchecked/Checked Separation
- Separator component: Visual divider between unchecked and checked items * Shows count of completed items with denominator styling * Prevents accidental drag across group boundaries * Smooth transitions with fade/slide animations - Sorting logic: Maintains unchecked items first, checked items last * Stable sort: Relative order within groups is preserved * Auto-updates on item toggle and reordering * Validates drag moves to same-group only - UI improvements: Enhanced LazyColumn animations * AnimatedVisibility for smooth item transitions * Added animateItem() for LazyColumn layout changes * Item elevation during drag state - Comprehensive test coverage * 9 unit tests for sorting logic validation * Edge cases: empty lists, single items, mixed groups * Verifies order reassignment and group separation Affected components: - CheckedItemsSeparator: New UI component for visual separation - NoteEditorViewModel: sortChecklistItems() method with validation - NoteEditorScreen: Separator integration & animation setup - ChecklistSortingTest: Complete test suite with 9 test cases - Localizations: German & English plurals
This commit is contained in:
@@ -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
|
||||
@@ -319,6 +325,11 @@ private fun ChecklistEditor(
|
||||
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,6 +341,11 @@ 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,
|
||||
@@ -345,30 +361,38 @@ private fun ChecklistEditor(
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ChecklistItemState>): List<ChecklistItemState> {
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -505,4 +505,10 @@
|
||||
<item quantity="one">%d Notiz synchronisiert</item>
|
||||
<item quantity="other">%d Notizen synchronisiert</item>
|
||||
</plurals>
|
||||
|
||||
<!-- v1.8.0 (IMPL_017): Checklist separator -->
|
||||
<plurals name="checked_items_count">
|
||||
<item quantity="one">%d erledigt</item>
|
||||
<item quantity="other">%d erledigt</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
|
||||
@@ -513,6 +513,12 @@
|
||||
<item quantity="other">%d notes synced</item>
|
||||
</plurals>
|
||||
|
||||
<!-- v1.8.0 (IMPL_017): Checklist separator -->
|
||||
<plurals name="checked_items_count">
|
||||
<item quantity="one">%d completed</item>
|
||||
<item quantity="other">%d completed</item>
|
||||
</plurals>
|
||||
|
||||
<!-- ============================= -->
|
||||
<!-- PARALLEL DOWNLOADS v1.8.0 -->
|
||||
<!-- ============================= -->
|
||||
|
||||
@@ -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<ChecklistItemState>): List<ChecklistItemState> {
|
||||
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<ChecklistItemState>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user