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
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 -->
|
||||
<!-- ============================= -->
|
||||
|
||||
Reference in New Issue
Block a user