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:
inventory69
2026-02-09 14:09:18 +01:00
parent 538a705def
commit 900dad76fe
6 changed files with 323 additions and 31 deletions

View File

@@ -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,

View File

@@ -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<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)

View File

@@ -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
)
}
}

View File

@@ -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>

View File

@@ -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 -->
<!-- ============================= -->