diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/DragDropListState.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/DragDropListState.kt index 0f6284e..09d393a 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/DragDropListState.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/DragDropListState.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.launch * v1.5.0: NoteEditor Redesign * v1.8.0: IMPL_023 - Drag & Drop Fix (pointerInput key + Handle-only drag) * v1.8.0: IMPL_023b - Flicker-Fix (Straddle-Target-Center-Erkennung statt Mittelpunkt) + * v1.8.1: IMPL_14 - Separator als eigenes Item, Cross-Boundary-Drag mit Auto-Toggle */ class DragDropListState( private val state: LazyListState, @@ -41,6 +42,9 @@ class DragDropListState( private var draggingItemSize by mutableStateOf(0) private var overscrollJob by mutableStateOf(null) + // 🆕 v1.8.1 IMPL_14: Visual-Index des Separators (-1 = kein Separator) + var separatorVisualIndex by mutableStateOf(-1) + val draggingItemOffset: Float get() = draggingItemLayoutInfo?.let { item -> draggingItemInitialOffset + draggingItemDraggedDelta - item.offset @@ -50,6 +54,23 @@ class DragDropListState( get() = state.layoutInfo.visibleItemsInfo .firstOrNull { it.index == draggingItemIndex } + /** + * 🆕 v1.8.1 IMPL_14: Visual-Index → Data-Index Konvertierung. + * Wenn ein Separator existiert, sind alle Items nach dem Separator um 1 verschoben. + */ + fun visualToDataIndex(visualIndex: Int): Int { + if (separatorVisualIndex < 0) return visualIndex + return if (visualIndex > separatorVisualIndex) visualIndex - 1 else visualIndex + } + + /** + * 🆕 v1.8.1 IMPL_14: Data-Index → Visual-Index Konvertierung. + */ + fun dataToVisualIndex(dataIndex: Int): Int { + if (separatorVisualIndex < 0) return dataIndex + return if (dataIndex >= separatorVisualIndex) dataIndex + 1 else dataIndex + } + fun onDragStart(offset: Offset, itemIndex: Int) { draggingItemIndex = itemIndex val info = draggingItemLayoutInfo @@ -78,9 +99,12 @@ class DragDropListState( // Statt den Mittelpunkt des gezogenen Items zu prüfen ("liegt mein Zentrum im Target?"), // wird geprüft ob das gezogene Item den MITTELPUNKT des Targets überspannt. // Dies verhindert Oszillation bei Items unterschiedlicher Größe. - // Zusätzlich: Nur adjazente Items (Index ± 1) als Swap-Kandidaten. + // 🆕 v1.8.1 IMPL_14: Separator überspringen, Adjazenz berücksichtigt Separator-Lücke val targetItem = state.layoutInfo.visibleItemsInfo.firstOrNull { item -> - (item.index == draggingItem.index - 1 || item.index == draggingItem.index + 1) && + // Separator überspringen + item.index != separatorVisualIndex && + // Nur adjazente Items (Separator-Lücke wird übersprungen) + isAdjacentSkippingSeparator(draggingItem.index, item.index) && run { val targetCenter = item.offset + item.size / 2 startOffset < targetCenter && endOffset > targetCenter @@ -95,16 +119,20 @@ class DragDropListState( } else { null } + + // 🆕 v1.8.1 IMPL_14: Visual-Indizes zu Data-Indizes konvertieren für onMove + val fromDataIndex = visualToDataIndex(draggingItem.index) + val toDataIndex = visualToDataIndex(targetItem.index) if (scrollToIndex != null) { scope.launch { state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset) - onMove(draggingItem.index, targetItem.index) + onMove(fromDataIndex, toDataIndex) // 🆕 v1.8.0: IMPL_023b — Index-Update NACH dem Move (verhindert Race-Condition) draggingItemIndex = targetItem.index } } else { - onMove(draggingItem.index, targetItem.index) + onMove(fromDataIndex, toDataIndex) draggingItemIndex = targetItem.index } } else { @@ -128,6 +156,26 @@ class DragDropListState( } } + /** + * 🆕 v1.8.1 IMPL_14: Prüft ob zwei Visual-Indizes adjazent sind, + * wobei der Separator übersprungen wird. + * Beispiel: Items bei Visual 1 und Visual 3 sind adjazent wenn Separator bei Visual 2 liegt. + */ + private fun isAdjacentSkippingSeparator(indexA: Int, indexB: Int): Boolean { + val diff = kotlin.math.abs(indexA - indexB) + if (diff == 1) { + // Direkt benachbart — aber NICHT wenn der Separator dazwischen liegt + val between = minOf(indexA, indexB) + 1 + return between != separatorVisualIndex || separatorVisualIndex < 0 + } + if (diff == 2 && separatorVisualIndex >= 0) { + // 2 Positionen entfernt — adjazent wenn Separator dazwischen + val between = minOf(indexA, indexB) + 1 + return between == separatorVisualIndex + } + return false + } + @Suppress("UnusedPrivateProperty") private val LazyListItemInfo.offsetEnd: Int get() = this.offset + this.size 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 f5e3e0b..dfe1cf5 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,11 +1,6 @@ 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 @@ -19,6 +14,7 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape @@ -327,6 +323,63 @@ private fun TextNoteContent( ) } +/** + * 🆕 v1.8.1 IMPL_14: Extrahiertes Composable für ein einzelnes draggbares Checklist-Item. + * Entkoppelt von der Separator-Logik — wiederverwendbar für unchecked und checked Items. + */ +@Composable +private fun LazyItemScope.DraggableChecklistItem( + item: ChecklistItemState, + visualIndex: Int, + dragDropState: DragDropListState, + focusNewItemId: String?, + onTextChange: (String, String) -> Unit, + onCheckedChange: (String, Boolean) -> Unit, + onDelete: (String) -> Unit, + onAddNewItemAfter: (String) -> Unit, + onFocusHandled: () -> Unit, +) { + val isDragging = dragDropState.draggingItemIndex == visualIndex + val elevation by animateDpAsState( + targetValue = if (isDragging) 8.dp else 0.dp, + label = "elevation" + ) + + val shouldFocus = item.id == focusNewItemId + + 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, + isAnyItemDragging = dragDropState.draggingItemIndex != null, + dragModifier = Modifier.dragContainer(dragDropState, visualIndex), + modifier = Modifier + .then(if (!isDragging) Modifier.animateItem() else Modifier) + .offset { + IntOffset( + 0, + if (isDragging) dragDropState.draggingItemOffset.roundToInt() else 0 + ) + } + .zIndex(if (isDragging) DRAGGING_ITEM_Z_INDEX else 0f) + .shadow(elevation, shape = RoundedCornerShape(ITEM_CORNER_RADIUS_DP.dp)) + .background( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(ITEM_CORNER_RADIUS_DP.dp) + ) + ) +} + @Suppress("LongParameterList") // Compose functions commonly have many callback parameters @Composable private fun ChecklistEditor( @@ -359,75 +412,61 @@ private fun ChecklistEditor( val showSeparator = shouldShowSeparator && uncheckedCount > 0 && checkedCount > 0 Column(modifier = modifier) { + // 🆕 v1.8.1 IMPL_14: Separator-Position für DragDropState aktualisieren + val separatorVisualIndex = if (showSeparator) uncheckedCount else -1 + LaunchedEffect(separatorVisualIndex) { + dragDropState.separatorVisualIndex = separatorVisualIndex + } + LazyColumn( state = listState, modifier = Modifier.weight(1f), contentPadding = PaddingValues(vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(2.dp) ) { + // 🆕 v1.8.1 IMPL_14: Unchecked Items (Visual Index 0..uncheckedCount-1) itemsIndexed( - items = items, + items = if (showSeparator) items.subList(0, uncheckedCount) else items, key = { _, item -> item.id } ) { index, item -> - // 🆕 v1.8.0 (IMPL_017): Separator vor dem ersten Checked-Item - // 🆕 v1.8.1: Separator während Drag ausblenden — verhindert Flächenveränderung - // am Separator-Item die Swap-Erkennung destabilisiert - if (showSeparator && index == uncheckedCount && dragDropState.draggingItemIndex == null) { - CheckedItemsSeparator(checkedCount = checkedCount) - } - - val isDragging = dragDropState.draggingItemIndex == index - val elevation by animateDpAsState( - targetValue = if (isDragging) 8.dp else 0.dp, - label = "elevation" + DraggableChecklistItem( + item = item, + visualIndex = index, + dragDropState = dragDropState, + focusNewItemId = focusNewItemId, + onTextChange = onTextChange, + onCheckedChange = onCheckedChange, + onDelete = onDelete, + onAddNewItemAfter = onAddNewItemAfter, + onFocusHandled = onFocusHandled ) + } - val shouldFocus = item.id == focusNewItemId - - // v1.5.0: Clear focus request after handling - LaunchedEffect(shouldFocus) { - if (shouldFocus) { - onFocusHandled() - } + // 🆕 v1.8.1 IMPL_14: Separator als eigenes LazyColumn-Item + if (showSeparator) { + item(key = "separator") { + CheckedItemsSeparator( + checkedCount = checkedCount, + isDragActive = dragDropState.draggingItemIndex != null + ) } - // 🆕 v1.8.0 (IMPL_017): AnimatedVisibility für sanfte Übergänge - AnimatedVisibility( - visible = true, - enter = fadeIn() + slideInVertically(), - exit = fadeOut() + slideOutVertically() - ) { - ChecklistItemRow( + // 🆕 v1.8.1 IMPL_14: Checked Items (Visual Index uncheckedCount+1..) + itemsIndexed( + items = items.subList(uncheckedCount, items.size), + key = { _, item -> item.id } + ) { index, item -> + val visualIndex = uncheckedCount + 1 + index // +1 für Separator + DraggableChecklistItem( item = item, - onTextChange = { onTextChange(item.id, it) }, - onCheckedChange = { onCheckedChange(item.id, it) }, - onDelete = { onDelete(item.id) }, - onAddNewItem = { onAddNewItemAfter(item.id) }, - requestFocus = shouldFocus, - // 🆕 v1.8.0: IMPL_023 - Drag state übergeben - isDragging = isDragging, - // 🆕 v1.8.0: IMPL_023 - Gradient während Drag ausblenden - isAnyItemDragging = dragDropState.draggingItemIndex != null, - // 🆕 v1.8.0: IMPL_023 - Drag nur auf Handle - dragModifier = Modifier.dragContainer(dragDropState, index), - modifier = Modifier - // 🆕 v1.8.1: animateItem NUR für nicht-gedraggte Items - // Bei gedraggten Items kämpft animateItem (Layout-Animation) - // gegen den manuellen offset (Finger-Position) → Flackern - .then(if (!isDragging) Modifier.animateItem() else Modifier) - .offset { - IntOffset( - 0, - if (isDragging) dragDropState.draggingItemOffset.roundToInt() else 0 - ) - } - // 🆕 v1.8.0: IMPL_023 - Gedraggtes Item liegt über anderen - .zIndex(if (isDragging) DRAGGING_ITEM_Z_INDEX else 0f) - .shadow(elevation, shape = RoundedCornerShape(ITEM_CORNER_RADIUS_DP.dp)) - .background( - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(ITEM_CORNER_RADIUS_DP.dp) - ) + visualIndex = visualIndex, + dragDropState = dragDropState, + focusNewItemId = focusNewItemId, + onTextChange = onTextChange, + onCheckedChange = onCheckedChange, + onDelete = onDelete, + onAddNewItemAfter = onAddNewItemAfter, + onFocusHandled = onFocusHandled ) } } 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 8f634a5..341d9d9 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 @@ -238,15 +238,18 @@ class NoteEditorViewModel( 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) + + // 🆕 v1.8.1 IMPL_14: Cross-Boundary Move mit Auto-Toggle + // Wenn ein Item die Grenze überschreitet, wird es automatisch checked/unchecked. + val movedItem = if (fromItem.isChecked != toItem.isChecked) { + item.copy(isChecked = toItem.isChecked) + } else { + item + } + + mutableList.add(toIndex, movedItem) // Update order values mutableList.mapIndexed { index, i -> i.copy(order = index) } } 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 index 1b8e1c1..60a7807 100644 --- 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 @@ -15,6 +15,7 @@ import dev.dettmer.simplenotes.R /** * 🆕 v1.8.0 (IMPL_017): Visueller Separator zwischen unchecked und checked Items + * 🆕 v1.8.1 (IMPL_14): Drag-Awareness — Primary-Farbe während Drag als visueller Hinweis * * Zeigt eine dezente Linie mit Anzahl der erledigten Items: * ── 3 completed ── @@ -22,7 +23,8 @@ import dev.dettmer.simplenotes.R @Composable fun CheckedItemsSeparator( checkedCount: Int, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + isDragActive: Boolean = false // 🆕 v1.8.1 IMPL_14 ) { Row( modifier = modifier @@ -32,7 +34,10 @@ fun CheckedItemsSeparator( ) { HorizontalDivider( modifier = Modifier.weight(1f), - color = MaterialTheme.colorScheme.outlineVariant + color = if (isDragActive) + MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) + else + MaterialTheme.colorScheme.outlineVariant ) Text( @@ -42,13 +47,19 @@ fun CheckedItemsSeparator( checkedCount ), style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.outline, + color = if (isDragActive) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.outline, modifier = Modifier.padding(horizontal = 12.dp) ) HorizontalDivider( modifier = Modifier.weight(1f), - color = MaterialTheme.colorScheme.outlineVariant + color = if (isDragActive) + MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) + else + MaterialTheme.colorScheme.outlineVariant ) } }