Skip to content

Commit da37143

Browse files
committed
fix(editor): IMPL_15 - Add Item respects separator position in checklist
Changes: - NoteEditorViewModel.kt: Add calculateInsertIndexForNewItem() helper method - NoteEditorViewModel.kt: Rewrite addChecklistItemAtEnd() to insert before first checked item (MANUAL/UNCHECKED_FIRST) instead of appending at list end - NoteEditorViewModel.kt: Add cross-boundary guard to addChecklistItemAfter() preventing new unchecked items from being inserted inside checked section - ChecklistSortingTest.kt: Add ChecklistSortOption import - ChecklistSortingTest.kt: Add 10 IMPL_15 unit tests covering all sort modes, edge cases (empty list, all checked, no checked), and position stability Root cause: addChecklistItemAtEnd() appended new unchecked items at the end of the flat list, after checked items. The UI splits items by count (subList(0, uncheckedCount)), not by isChecked state — causing checked items to appear above the separator and new items below it. Fix: Insert new items at the semantically correct position per sort mode. MANUAL/UNCHECKED_FIRST: before first checked item (above separator). All other modes: at list end (no separator visible, no visual issue). All 19 unit tests pass (9 existing + 10 new). No UI changes required.
1 parent cf54f44 commit da37143

2 files changed

Lines changed: 260 additions & 4 deletions

File tree

android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorViewModel.kt

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -244,13 +244,34 @@ class NoteEditorViewModel(
244244
}
245245
}
246246

247+
/**
248+
* 🆕 v1.8.1 (IMPL_15): Fügt ein neues Item nach dem angegebenen Item ein.
249+
*
250+
* Guard: Bei MANUAL/UNCHECKED_FIRST wird sichergestellt, dass das neue (unchecked)
251+
* Item nicht innerhalb der checked-Sektion eingefügt wird. Falls das Trigger-Item
252+
* checked ist, wird stattdessen vor dem ersten checked Item eingefügt.
253+
*/
247254
fun addChecklistItemAfter(afterItemId: String): String {
248255
val newItem = ChecklistItemState.createEmpty(0)
249256
_checklistItems.update { items ->
250257
val index = items.indexOfFirst { it.id == afterItemId }
251258
if (index >= 0) {
259+
val currentSort = _lastChecklistSortOption.value
260+
val hasSeparator = currentSort == ChecklistSortOption.MANUAL ||
261+
currentSort == ChecklistSortOption.UNCHECKED_FIRST
262+
263+
// 🆕 v1.8.1 (IMPL_15): Wenn das Trigger-Item checked ist und ein Separator
264+
// existiert, darf das neue unchecked Item nicht in die checked-Sektion.
265+
// → Stattdessen vor dem ersten checked Item einfügen.
266+
val effectiveIndex = if (hasSeparator && items[index].isChecked) {
267+
val firstCheckedIndex = items.indexOfFirst { it.isChecked }
268+
if (firstCheckedIndex >= 0) firstCheckedIndex else index + 1
269+
} else {
270+
index + 1
271+
}
272+
252273
val newList = items.toMutableList()
253-
newList.add(index + 1, newItem)
274+
newList.add(effectiveIndex, newItem)
254275
// Update order values
255276
newList.mapIndexed { i, item -> item.copy(order = i) }
256277
} else {
@@ -259,12 +280,46 @@ class NoteEditorViewModel(
259280
}
260281
return newItem.id
261282
}
262-
283+
284+
/**
285+
* 🆕 v1.8.1 (IMPL_15): Fügt ein neues Item an der semantisch korrekten Position ein.
286+
*
287+
* Bei MANUAL/UNCHECKED_FIRST: Vor dem ersten checked Item (= direkt über dem Separator).
288+
* Bei allen anderen Modi: Am Ende der Liste (kein Separator sichtbar).
289+
*
290+
* Verhindert, dass checked Items über den Separator springen oder das neue Item
291+
* unter dem Separator erscheint.
292+
*/
263293
fun addChecklistItemAtEnd(): String {
264-
val newItem = ChecklistItemState.createEmpty(_checklistItems.value.size)
265-
_checklistItems.update { items -> items + newItem }
294+
val newItem = ChecklistItemState.createEmpty(0)
295+
_checklistItems.update { items ->
296+
val insertIndex = calculateInsertIndexForNewItem(items)
297+
val newList = items.toMutableList()
298+
newList.add(insertIndex, newItem)
299+
newList.mapIndexed { i, item -> item.copy(order = i) }
300+
}
266301
return newItem.id
267302
}
303+
304+
/**
305+
* 🆕 v1.8.1 (IMPL_15): Berechnet die korrekte Insert-Position für ein neues unchecked Item.
306+
*
307+
* - MANUAL / UNCHECKED_FIRST: Vor dem ersten checked Item (direkt über dem Separator)
308+
* - Alle anderen Modi: Am Ende der Liste (kein Separator, kein visuelles Problem)
309+
*
310+
* Falls keine checked Items existieren, wird am Ende eingefügt.
311+
*/
312+
private fun calculateInsertIndexForNewItem(items: List<ChecklistItemState>): Int {
313+
val currentSort = _lastChecklistSortOption.value
314+
return when (currentSort) {
315+
ChecklistSortOption.MANUAL,
316+
ChecklistSortOption.UNCHECKED_FIRST -> {
317+
val firstCheckedIndex = items.indexOfFirst { it.isChecked }
318+
if (firstCheckedIndex >= 0) firstCheckedIndex else items.size
319+
}
320+
else -> items.size
321+
}
322+
}
268323

269324
fun deleteChecklistItem(itemId: String) {
270325
_checklistItems.update { items ->

android/app/src/test/java/dev/dettmer/simplenotes/ui/editor/ChecklistSortingTest.kt

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package dev.dettmer.simplenotes.ui.editor
22

3+
import dev.dettmer.simplenotes.models.ChecklistSortOption
34
import org.junit.Assert.*
45
import org.junit.Test
56

@@ -174,4 +175,204 @@ class ChecklistSortingTest {
174175
assertEquals(1, sorted[1].order)
175176
assertEquals(2, sorted[2].order)
176177
}
178+
179+
// ═══════════════════════════════════════════════════════════════════════
180+
// 🆕 v1.8.1 (IMPL_15): Tests für Add-Item Insert-Position
181+
// ═══════════════════════════════════════════════════════════════════════
182+
183+
/**
184+
* Simulates calculateInsertIndexForNewItem() from NoteEditorViewModel.
185+
* Tests the insert position logic for new unchecked items.
186+
*/
187+
private fun calculateInsertIndexForNewItem(
188+
items: List<ChecklistItemState>,
189+
sortOption: ChecklistSortOption
190+
): Int {
191+
return when (sortOption) {
192+
ChecklistSortOption.MANUAL,
193+
ChecklistSortOption.UNCHECKED_FIRST -> {
194+
val firstCheckedIndex = items.indexOfFirst { it.isChecked }
195+
if (firstCheckedIndex >= 0) firstCheckedIndex else items.size
196+
}
197+
else -> items.size
198+
}
199+
}
200+
201+
/**
202+
* Simulates the full addChecklistItemAtEnd() logic:
203+
* 1. Calculate insert index
204+
* 2. Insert new item
205+
* 3. Reassign order values
206+
*/
207+
private fun simulateAddItemAtEnd(
208+
items: List<ChecklistItemState>,
209+
sortOption: ChecklistSortOption
210+
): List<ChecklistItemState> {
211+
val newItem = ChecklistItemState(id = "new", text = "", isChecked = false, order = 0)
212+
val insertIndex = calculateInsertIndexForNewItem(items, sortOption)
213+
val newList = items.toMutableList()
214+
newList.add(insertIndex, newItem)
215+
return newList.mapIndexed { i, item -> item.copy(order = i) }
216+
}
217+
218+
@Test
219+
fun `IMPL_15 - add item at end inserts before separator in MANUAL mode`() {
220+
// Ausgangslage: 2 unchecked, 1 checked (sortiert)
221+
val items = listOf(
222+
item("a", checked = false, order = 0),
223+
item("b", checked = false, order = 1),
224+
item("c", checked = true, order = 2)
225+
)
226+
227+
val result = simulateAddItemAtEnd(items, ChecklistSortOption.MANUAL)
228+
229+
// Neues Item muss an Index 2 stehen (vor dem checked Item)
230+
assertEquals(4, result.size)
231+
assertEquals("a", result[0].id)
232+
assertEquals("b", result[1].id)
233+
assertEquals("new", result[2].id) // ← Neues Item VOR Separator
234+
assertFalse(result[2].isChecked)
235+
assertEquals("c", result[3].id) // ← Checked Item bleibt UNTER Separator
236+
assertTrue(result[3].isChecked)
237+
}
238+
239+
@Test
240+
fun `IMPL_15 - add item at end inserts before separator in UNCHECKED_FIRST mode`() {
241+
val items = listOf(
242+
item("a", checked = false, order = 0),
243+
item("b", checked = true, order = 1),
244+
item("c", checked = true, order = 2)
245+
)
246+
247+
val result = simulateAddItemAtEnd(items, ChecklistSortOption.UNCHECKED_FIRST)
248+
249+
assertEquals(4, result.size)
250+
assertEquals("a", result[0].id)
251+
assertEquals("new", result[1].id) // ← Neues Item direkt nach letztem unchecked
252+
assertFalse(result[1].isChecked)
253+
assertEquals("b", result[2].id)
254+
assertEquals("c", result[3].id)
255+
}
256+
257+
@Test
258+
fun `IMPL_15 - add item at end appends at end in CHECKED_FIRST mode`() {
259+
val items = listOf(
260+
item("a", checked = true, order = 0),
261+
item("b", checked = false, order = 1)
262+
)
263+
264+
val result = simulateAddItemAtEnd(items, ChecklistSortOption.CHECKED_FIRST)
265+
266+
assertEquals(3, result.size)
267+
assertEquals("a", result[0].id)
268+
assertEquals("b", result[1].id)
269+
assertEquals("new", result[2].id) // ← Am Ende (kein Separator)
270+
}
271+
272+
@Test
273+
fun `IMPL_15 - add item at end appends at end in ALPHABETICAL_ASC mode`() {
274+
val items = listOf(
275+
item("a", checked = false, order = 0),
276+
item("b", checked = true, order = 1)
277+
)
278+
279+
val result = simulateAddItemAtEnd(items, ChecklistSortOption.ALPHABETICAL_ASC)
280+
281+
assertEquals(3, result.size)
282+
assertEquals("new", result[2].id) // ← Am Ende
283+
}
284+
285+
@Test
286+
fun `IMPL_15 - add item at end appends at end in ALPHABETICAL_DESC mode`() {
287+
val items = listOf(
288+
item("a", checked = true, order = 0),
289+
item("b", checked = false, order = 1)
290+
)
291+
292+
val result = simulateAddItemAtEnd(items, ChecklistSortOption.ALPHABETICAL_DESC)
293+
294+
assertEquals(3, result.size)
295+
assertEquals("new", result[2].id) // ← Am Ende
296+
}
297+
298+
@Test
299+
fun `IMPL_15 - add item with no checked items appends at end`() {
300+
val items = listOf(
301+
item("a", checked = false, order = 0),
302+
item("b", checked = false, order = 1)
303+
)
304+
305+
val result = simulateAddItemAtEnd(items, ChecklistSortOption.MANUAL)
306+
307+
assertEquals(3, result.size)
308+
assertEquals("new", result[2].id) // Kein checked Item → ans Ende
309+
}
310+
311+
@Test
312+
fun `IMPL_15 - add item with all checked items inserts at position 0`() {
313+
val items = listOf(
314+
item("a", checked = true, order = 0),
315+
item("b", checked = true, order = 1)
316+
)
317+
318+
val result = simulateAddItemAtEnd(items, ChecklistSortOption.MANUAL)
319+
320+
assertEquals(3, result.size)
321+
assertEquals("new", result[0].id) // ← Ganz oben (vor allen checked Items)
322+
assertFalse(result[0].isChecked)
323+
assertEquals("a", result[1].id)
324+
assertEquals("b", result[2].id)
325+
}
326+
327+
@Test
328+
fun `IMPL_15 - add item to empty list in MANUAL mode`() {
329+
val items = emptyList<ChecklistItemState>()
330+
331+
val result = simulateAddItemAtEnd(items, ChecklistSortOption.MANUAL)
332+
333+
assertEquals(1, result.size)
334+
assertEquals("new", result[0].id)
335+
assertEquals(0, result[0].order)
336+
}
337+
338+
@Test
339+
fun `IMPL_15 - order values are sequential after add item`() {
340+
val items = listOf(
341+
item("a", checked = false, order = 0),
342+
item("b", checked = false, order = 1),
343+
item("c", checked = true, order = 2)
344+
)
345+
346+
val result = simulateAddItemAtEnd(items, ChecklistSortOption.MANUAL)
347+
348+
result.forEachIndexed { index, item ->
349+
assertEquals("Order at index $index should be $index", index, item.order)
350+
}
351+
}
352+
353+
@Test
354+
fun `IMPL_15 - existing items do not change position after add item`() {
355+
// Kernforderung: Kein Item darf sich verschieben
356+
val items = listOf(
357+
item("cashews", checked = false, order = 0),
358+
item("noodles", checked = false, order = 1),
359+
item("coffee", checked = true, order = 2)
360+
)
361+
362+
val result = simulateAddItemAtEnd(items, ChecklistSortOption.MANUAL)
363+
364+
// Relative Reihenfolge der bestehenden Items prüfen
365+
val existingIds = result.filter { it.id != "new" }.map { it.id }
366+
assertEquals(listOf("cashews", "noodles", "coffee"), existingIds)
367+
368+
// Cashews und Noodles müssen VOR dem neuen Item sein
369+
val cashewsIdx = result.indexOfFirst { it.id == "cashews" }
370+
val noodlesIdx = result.indexOfFirst { it.id == "noodles" }
371+
val newIdx = result.indexOfFirst { it.id == "new" }
372+
val coffeeIdx = result.indexOfFirst { it.id == "coffee" }
373+
374+
assertTrue("Cashews before new", cashewsIdx < newIdx)
375+
assertTrue("Noodles before new", noodlesIdx < newIdx)
376+
assertTrue("New before Coffee", newIdx < coffeeIdx)
377+
}
177378
}

0 commit comments

Comments
 (0)