diff --git a/app/src/main/java/com/philkes/notallyx/presentation/view/note/listitem/ListManager.kt b/app/src/main/java/com/philkes/notallyx/presentation/view/note/listitem/ListManager.kt index e4fadd9b..1ca154d5 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/view/note/listitem/ListManager.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/view/note/listitem/ListManager.kt @@ -37,6 +37,8 @@ data class ListState( val cursorPos: Int? = null, ) +data class CheckedItemSuggestion(val id: Int, val body: String) + /** * Should be used for all changes to the items list. Notifies the [RecyclerView.Adapter] and pushes * according changes to the [ChangeHistory] @@ -401,6 +403,74 @@ class ListManager( return if (fromCheckedList) itemsChecked!![position] else items[position] } + internal fun getCheckedItemSuggestions( + query: CharSequence, + currentItemId: Int, + ): List { + val checkedItems = + items.filter { it.checked } + + (itemsChecked?.toMutableList()?.filter { it.checked } ?: emptyList()) + return checkedItems + .asSequence() + .filter { it.id != currentItemId && it.body.isNotBlank() } + .filter { matchesCheckedItem(query.toString(), it.body) } + .map { CheckedItemSuggestion(it.id, it.body.trim()) } + .distinctBy { it.body.lowercase() } + .take(MAX_AUTOCOMPLETE_SUGGESTIONS) + .toList() + } + + internal fun completeWithCheckedItem(currentItemId: Int, checkedItemId: Int) { + val stateBefore = getState() + val stateAfter = createAutocompleteState(stateBefore, currentItemId, checkedItemId) ?: return + changeHistory.push(ListBatchChange(stateBefore, stateAfter, this)) + setState(stateAfter) + onItemSizeChanged?.invoke(stateAfter.items.size + (stateAfter.checkedItems?.size ?: 0)) + } + + private fun createAutocompleteState( + stateBefore: ListState, + currentItemId: Int, + checkedItemId: Int, + ): ListState? { + val itemsAfter = stateBefore.items.cloneList() + val checkedItemsAfter = stateBefore.checkedItems?.cloneList() + val currentItem = itemsAfter.find { it.id == currentItemId } ?: return null + val sourceItems = + if (itemsAfter.any { it.id == checkedItemId && it.checked }) { + itemsAfter + } else { + checkedItemsAfter ?: return null + } + val checkedItem = sourceItems.find { it.id == checkedItemId && it.checked } ?: return null + + currentItem.body = checkedItem.body.trim() + currentItem.check(false, checkChildren = false) + + sourceItems.removeFromParent(checkedItem) + sourceItems.remove(checkedItem) + checkedItem.children.forEach { it.isChild = false } + checkedItem.children.clear() + + val currentPosition = itemsAfter.indexOfFirst { it.id == currentItem.id } + if (currentPosition == -1) return null + + val orderedIds = + (stateBefore.items + (stateBefore.checkedItems ?: emptyList())) + .sortedBy { it.order } + .map { it.id } + .filterNot { it == checkedItem.id } + val finalItems = itemsAfter + (checkedItemsAfter ?: emptyList()) + orderedIds.forEachIndexed { order, id -> finalItems.find { it.id == id }?.order = order } + + return ListState( + itemsAfter, + checkedItemsAfter, + focusedItemPos = currentPosition, + cursorPos = currentItem.body.length, + ) + } + private fun RecyclerView.getFocusedPositionAndCursor(): Pair { return focusedChild?.let { view -> val position = getChildAdapterPosition(view) @@ -579,5 +649,15 @@ class ListManager( companion object { private const val TAG = "ListManager" + private const val MAX_AUTOCOMPLETE_SUGGESTIONS = 2 } } + +internal fun matchesCheckedItem(query: String, candidate: String): Boolean { + val normalizedQuery = query.trim() + val normalizedCandidate = candidate.trim() + return normalizedQuery.length >= MIN_AUTOCOMPLETE_QUERY_LENGTH && + normalizedCandidate.startsWith(normalizedQuery, ignoreCase = true) +} + +private const val MIN_AUTOCOMPLETE_QUERY_LENGTH = 2 diff --git a/app/src/main/java/com/philkes/notallyx/presentation/view/note/listitem/adapter/ListItemVH.kt b/app/src/main/java/com/philkes/notallyx/presentation/view/note/listitem/adapter/ListItemVH.kt index 3136e968..38e6e5e0 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/view/note/listitem/adapter/ListItemVH.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/view/note/listitem/adapter/ListItemVH.kt @@ -6,11 +6,13 @@ import android.view.MotionEvent import android.view.View.GONE import android.view.ViewGroup import android.view.inputmethod.InputMethodManager +import android.widget.ArrayAdapter import android.widget.CompoundButton.OnCheckedChangeListener import android.widget.EditText import android.widget.TextView.INVISIBLE import android.widget.TextView.VISIBLE import androidx.annotation.ColorInt +import androidx.appcompat.widget.ListPopupWindow import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.NO_POSITION @@ -28,6 +30,7 @@ import com.philkes.notallyx.presentation.setControlsContrastColorForAllViews import com.philkes.notallyx.presentation.setOnNextAction import com.philkes.notallyx.presentation.setTextSizeSp import com.philkes.notallyx.presentation.view.misc.EditTextAutoClearFocus +import com.philkes.notallyx.presentation.view.note.listitem.CheckedItemSuggestion import com.philkes.notallyx.presentation.view.note.listitem.ListManager import com.philkes.notallyx.presentation.view.note.listitem.firstBodyOrEmptyString import com.philkes.notallyx.presentation.viewmodel.preference.ListItemSort @@ -46,6 +49,9 @@ class ListItemVH( ) : RecyclerView.ViewHolder(binding.root) { private var dragHandleInitialY: Float = 0f + private val suggestionPopup = ListPopupWindow(binding.root.context) + private var suggestions: List = emptyList() + private var boundItemId: Int = NO_POSITION init { val body = textSize.editBodySize @@ -57,14 +63,29 @@ class ListItemVH( listManager, this@ListItemVH::getAdapterPosition, ) { text, start, count -> - if (count > 1) { - checkListPasted(text, start, count, this) - } else { - false + val listPasteHandled = + if (count > 1) { + checkListPasted(text, start, count, this) + } else { + false + } + if (!listPasteHandled) { + updateSuggestions(text) } + listPasteHandled } } + suggestionPopup.apply { + anchorView = binding.EditText + isModal = false + setOnItemClickListener { _, _, position, _ -> + val suggestion = suggestions[position] + suggestionPopup.dismiss() + listManager.completeWithCheckedItem(boundItemId, suggestion.id) + } + } + binding.DragHandle.setOnTouchListener { _, event -> when (event.actionMasked) { MotionEvent.ACTION_DOWN -> dragHandleInitialY = event.y @@ -95,6 +116,8 @@ class ListItemVH( autoSort: ListItemSort, viewMode: NoteViewMode, ) { + suggestionPopup.dismiss() + boundItemId = item.id updateEditText(item, position, viewMode) updateCheckBox(item, position) @@ -164,6 +187,7 @@ class ListItemVH( if (viewMode == NoteViewMode.EDIT) { setOnFocusChangeListener { _, hasFocus -> binding.Delete.visibility = if (hasFocus) VISIBLE else INVISIBLE + if (!hasFocus) suggestionPopup.dismiss() } binding.Content.descendantFocusability = ViewGroup.FOCUS_BEFORE_DESCENDANTS } else { @@ -288,4 +312,25 @@ class ListItemVH( } fun getSelection() = with(binding.EditText) { Pair(selectionStart, selectionEnd) } + + private fun updateSuggestions(text: CharSequence) { + if (!binding.EditText.hasFocus() || boundItemId == NO_POSITION || text.isBlank()) { + suggestionPopup.dismiss() + return + } + suggestions = listManager.getCheckedItemSuggestions(text, boundItemId) + if (suggestions.isEmpty()) { + suggestionPopup.dismiss() + return + } + suggestionPopup.setAdapter( + ArrayAdapter( + binding.root.context, + android.R.layout.simple_list_item_1, + suggestions.map { it.body }, + ) + ) + suggestionPopup.width = binding.EditText.width + suggestionPopup.show() + } } diff --git a/app/src/test/kotlin/com/philkes/notallyx/presentation/view/note/listitem/CheckedItemAutocompleteTest.kt b/app/src/test/kotlin/com/philkes/notallyx/presentation/view/note/listitem/CheckedItemAutocompleteTest.kt new file mode 100644 index 00000000..b984a68b --- /dev/null +++ b/app/src/test/kotlin/com/philkes/notallyx/presentation/view/note/listitem/CheckedItemAutocompleteTest.kt @@ -0,0 +1,35 @@ +package com.philkes.notallyx.presentation.view.note.listitem + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class CheckedItemAutocompleteTest { + + @Test + fun `matches candidate beginning case insensitively`() { + assertThat(matchesCheckedItem("lat", "Latte intero")).isTrue() + assertThat(matchesCheckedItem("LATTE I", "latte intero")).isTrue() + } + + @Test + fun `does not match letters found after candidate beginning`() { + assertThat(matchesCheckedItem("latte", "Comprare latte")).isFalse() + } + + @Test + fun `does not suggest blank text`() { + assertThat(matchesCheckedItem(" ", "Latte intero")).isFalse() + } + + @Test + fun `matches text equal to candidate case insensitively`() { + assertThat(matchesCheckedItem("Latte", "Latte")).isTrue() + assertThat(matchesCheckedItem("LATTE", "latte")).isTrue() + } + + @Test + fun `requires at least two typed letters`() { + assertThat(matchesCheckedItem("l", "Latte intero")).isFalse() + assertThat(matchesCheckedItem("la", "Latte intero")).isTrue() + } +} diff --git a/app/src/test/kotlin/com/philkes/notallyx/recyclerview/listmanager/ListManagerCheckedTest.kt b/app/src/test/kotlin/com/philkes/notallyx/recyclerview/listmanager/ListManagerCheckedTest.kt index 242cc512..8c18b639 100644 --- a/app/src/test/kotlin/com/philkes/notallyx/recyclerview/listmanager/ListManagerCheckedTest.kt +++ b/app/src/test/kotlin/com/philkes/notallyx/recyclerview/listmanager/ListManagerCheckedTest.kt @@ -500,6 +500,106 @@ class ListManagerCheckedTest : ListManagerTestBase() { items.assertChecked(false, false, false, false, false, false) } + @Test + fun `autocomplete applies delete and uncheck atomically without auto-sort`() { + setSorting(ListItemSort.NO_AUTO_SORT) + listManager.changeChecked(1, true) + changeHistory.reset() + + listManager.completeWithCheckedItem(currentItemId = 0, checkedItemId = 1) + + items.assertOrder("B", "C", "D", "E", "F") + "B".assertIsNotChecked() + changeHistory.undo() + items.assertOrder("A", "B", "C", "D", "E", "F") + "B".assertIsChecked() + } + + @Test + fun `autocomplete applies delete and uncheck atomically with auto-sort`() { + setSorting(ListItemSort.AUTO_SORT_BY_CHECKED) + listManager.changeChecked(1, true) + changeHistory.reset() + + listManager.completeWithCheckedItem(currentItemId = 0, checkedItemId = 1) + + itemsChecked!!.assertSize(0) + items.assertOrder("B", "C", "D", "E", "F") + "B".assertIsNotChecked() + changeHistory.undo() + items.assertOrder("A", "C", "D", "E", "F") + itemsChecked!!.assertOrder("B") + "B".assertIsChecked() + } + + @Test + fun `autocomplete replaces parent text and removes its checked child`() { + setSorting(ListItemSort.NO_AUTO_SORT) + listManager.changeIsChild(1, true) + listManager.changeIsChild(2, true) + listManager.changeChecked(1, true) + changeHistory.reset() + + listManager.completeWithCheckedItem(currentItemId = 0, checkedItemId = 1) + + items.assertOrder("B", "C", "D", "E", "F") + items.assertSize(5) + "B".assertChildren("C") + "B".assertIsNotChecked() + changeHistory.undo() + items.assertOrder("A", "B", "C", "D", "E", "F") + "A".assertChildren("B", "C") + "B".assertIsChecked() + } + + @Test + fun `autocomplete replaces parent text and removes its checked child with auto-sort`() { + setSorting(ListItemSort.AUTO_SORT_BY_CHECKED) + listManager.changeIsChild(1, true) + listManager.changeIsChild(2, true) + listManager.changeChecked(1, true) + + listManager.completeWithCheckedItem(currentItemId = 0, checkedItemId = 1) + + items.assertOrder("B", "C", "D", "E", "F") + itemsChecked!!.assertSize(0) + "B".assertChildren("C") + "B".assertIsNotChecked() + } + + @Test + fun `autocomplete keeps current children and promotes selected parent children`() { + setSorting(ListItemSort.NO_AUTO_SORT) + listManager.changeIsChild(1, true) + listManager.changeIsChild(3, true) + listManager.changeChecked(2, true) + + listManager.completeWithCheckedItem(currentItemId = 0, checkedItemId = 2) + + items.assertOrder("C", "B", "D", "E", "F") + "C".assertChildren("B") + "D".assertIsParent() + "C".assertIsNotChecked() + "B".assertIsNotChecked() + "D".assertIsChecked() + } + + @Test + fun `autocomplete promotes children from auto-sorted parent`() { + setSorting(ListItemSort.AUTO_SORT_BY_CHECKED) + listManager.changeIsChild(1, true) + listManager.changeIsChild(3, true) + listManager.changeChecked(2, true) + + listManager.completeWithCheckedItem(currentItemId = 0, checkedItemId = 2) + + items.assertOrder("C", "B", "E", "F") + itemsChecked!!.assertOrder("D") + "C".assertChildren("B") + "D".assertIsParent() + "C".assertIsNotChecked() + } + // endregion }