Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -401,6 +403,74 @@ class ListManager(
return if (fromCheckedList) itemsChecked!![position] else items[position]
}

internal fun getCheckedItemSuggestions(
query: CharSequence,
currentItemId: Int,
): List<CheckedItemSuggestion> {
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<Int?, Int?> {
return focusedChild?.let { view ->
val position = getChildAdapterPosition(view)
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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<CheckedItemSuggestion> = emptyList()
private var boundItemId: Int = NO_POSITION

init {
val body = textSize.editBodySize
Expand All @@ -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
Expand Down Expand Up @@ -95,6 +116,8 @@ class ListItemVH(
autoSort: ListItemSort,
viewMode: NoteViewMode,
) {
suggestionPopup.dismiss()
boundItemId = item.id
updateEditText(item, position, viewMode)

updateCheckBox(item, position)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

}