Message fuzzy search (#869)

This commit is contained in:
Dominik Korsa 2020-06-14 14:05:24 +02:00 committed by GitHub
parent 924bcb0d64
commit 6e1ddb482e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 130 additions and 78 deletions

View File

@ -142,7 +142,7 @@ dependencies {
implementation "androidx.constraintlayout:constraintlayout:1.1.3" implementation "androidx.constraintlayout:constraintlayout:1.1.3"
implementation "androidx.coordinatorlayout:coordinatorlayout:1.1.0" implementation "androidx.coordinatorlayout:coordinatorlayout:1.1.0"
implementation "com.google.android.material:material:1.1.0" implementation "com.google.android.material:material:1.1.0"
implementation "com.github.wulkanowy:material-chips-input:2.0.1" implementation "com.github.wulkanowy:material-chips-input:2.1.1"
implementation "com.github.PhilJay:MPAndroidChart:v3.1.0" implementation "com.github.PhilJay:MPAndroidChart:v3.1.0"
implementation "me.zhanghai.android.materialprogressbar:library:1.6.1" implementation "me.zhanghai.android.materialprogressbar:library:1.6.1"
@ -180,6 +180,7 @@ dependencies {
implementation 'com.wdullaer:materialdatetimepicker:4.2.3' implementation 'com.wdullaer:materialdatetimepicker:4.2.3'
implementation "io.coil-kt:coil:0.11.0" implementation "io.coil-kt:coil:0.11.0"
implementation "io.github.wulkanowy:AppKillerManager:3.0.0" implementation "io.github.wulkanowy:AppKillerManager:3.0.0"
implementation 'me.xdrop:fuzzywuzzy:1.3.1'
playImplementation 'com.google.firebase:firebase-analytics:17.4.3' playImplementation 'com.google.firebase:firebase-analytics:17.4.3'
playImplementation 'com.google.firebase:firebase-inappmessaging-display-ktx:19.0.7' playImplementation 'com.google.firebase:firebase-inappmessaging-display-ktx:19.0.7'

View File

@ -4,10 +4,9 @@ import android.graphics.Typeface
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_POSITION import androidx.recyclerview.widget.RecyclerView.NO_POSITION
import androidx.recyclerview.widget.SortedList
import androidx.recyclerview.widget.SortedListAdapterCallback
import io.github.wulkanowy.R import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.Message import io.github.wulkanowy.data.db.entities.Message
import io.github.wulkanowy.data.repositories.message.MessageFolder import io.github.wulkanowy.data.repositories.message.MessageFolder
@ -20,39 +19,23 @@ class MessageTabAdapter @Inject constructor() :
var onClickListener: (Message, position: Int) -> Unit = { _, _ -> } var onClickListener: (Message, position: Int) -> Unit = { _, _ -> }
private val items = SortedList(Message::class.java, object : private var items = mutableListOf<Message>()
SortedListAdapterCallback<Message>(this) {
override fun compare(item1: Message, item2: Message): Int { fun setDataItems(data: List<Message>) {
return item2.date.compareTo(item1.date) val diffResult = DiffUtil.calculateDiff(MessageTabDiffUtil(items, data))
} items = data.toMutableList()
diffResult.dispatchUpdatesTo(this)
override fun areContentsTheSame(oldItem: Message?, newItem: Message?): Boolean {
return oldItem == newItem
}
override fun areItemsTheSame(item1: Message, item2: Message): Boolean {
return item1 == item2
}
})
fun replaceAll(models: List<Message>) {
items.beginBatchedUpdates()
for (i in items.size() - 1 downTo 0) {
val model = items.get(i)
if (model !in models) {
items.remove(model)
}
}
items.addAll(models)
items.endBatchedUpdates()
} }
fun updateItem(position: Int, item: Message) { fun updateItem(position: Int, item: Message) {
items.updateItemAt(position, item) val currentItem = items[position]
items[position] = item
if (item != currentItem) {
notifyItemChanged(position)
}
} }
override fun getItemCount() = items.size() override fun getItemCount() = items.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ItemViewHolder( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ItemViewHolder(
ItemMessageBinding.inflate(LayoutInflater.from(parent.context), parent, false) ItemMessageBinding.inflate(LayoutInflater.from(parent.context), parent, false)
@ -85,4 +68,19 @@ class MessageTabAdapter @Inject constructor() :
} }
class ItemViewHolder(val binding: ItemMessageBinding) : RecyclerView.ViewHolder(binding.root) class ItemViewHolder(val binding: ItemMessageBinding) : RecyclerView.ViewHolder(binding.root)
private class MessageTabDiffUtil(private val old: List<Message>, private val new: List<Message>) :
DiffUtil.Callback() {
override fun getOldListSize(): Int = old.size
override fun getNewListSize(): Int = new.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return old[oldItemPosition].id == new[newItemPosition].id
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return old[oldItemPosition] == new[newItemPosition]
}
}
} }

View File

@ -90,7 +90,7 @@ class MessageTabFragment : BaseFragment<FragmentMessageTabBinding>(R.layout.frag
} }
override fun updateData(data: List<Message>) { override fun updateData(data: List<Message>) {
tabAdapter.replaceAll(data) tabAdapter.setDataItems(data)
} }
override fun updateItem(item: Message, position: Int) { override fun updateItem(item: Message, position: Int) {

View File

@ -1,6 +1,5 @@
package io.github.wulkanowy.ui.modules.message.tab package io.github.wulkanowy.ui.modules.message.tab
import android.annotation.SuppressLint
import io.github.wulkanowy.data.db.entities.Message import io.github.wulkanowy.data.db.entities.Message
import io.github.wulkanowy.data.repositories.message.MessageFolder import io.github.wulkanowy.data.repositories.message.MessageFolder
import io.github.wulkanowy.data.repositories.message.MessageRepository import io.github.wulkanowy.data.repositories.message.MessageRepository
@ -11,8 +10,13 @@ import io.github.wulkanowy.ui.base.ErrorHandler
import io.github.wulkanowy.utils.FirebaseAnalyticsHelper import io.github.wulkanowy.utils.FirebaseAnalyticsHelper
import io.github.wulkanowy.utils.SchedulersProvider import io.github.wulkanowy.utils.SchedulersProvider
import io.github.wulkanowy.utils.toFormattedString import io.github.wulkanowy.utils.toFormattedString
import io.reactivex.subjects.PublishSubject
import me.xdrop.fuzzywuzzy.FuzzySearch
import timber.log.Timber import timber.log.Timber
import java.util.Locale
import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.pow
class MessageTabPresenter @Inject constructor( class MessageTabPresenter @Inject constructor(
schedulers: SchedulersProvider, schedulers: SchedulersProvider,
@ -31,9 +35,12 @@ class MessageTabPresenter @Inject constructor(
private var messages = emptyList<Message>() private var messages = emptyList<Message>()
private val searchQuery = PublishSubject.create<String>()
fun onAttachView(view: MessageTabView, folder: MessageFolder) { fun onAttachView(view: MessageTabView, folder: MessageFolder) {
super.onAttachView(view) super.onAttachView(view)
view.initView() view.initView()
initializeSearchStream()
errorHandler.showErrorMessage = ::showErrorViewOnError errorHandler.showErrorMessage = ::showErrorViewOnError
this.folder = folder this.folder = folder
} }
@ -76,9 +83,7 @@ class MessageTabPresenter @Inject constructor(
private fun loadData(forceRefresh: Boolean) { private fun loadData(forceRefresh: Boolean) {
Timber.i("Loading $folder message data started") Timber.i("Loading $folder message data started")
disposable.apply { disposable.add(studentRepository.getCurrentStudent()
clear()
add(studentRepository.getCurrentStudent()
.flatMap { student -> .flatMap { student ->
semesterRepository.getCurrentSemester(student) semesterRepository.getCurrentSemester(student)
.flatMap { messageRepository.getMessages(student, it, folder, forceRefresh) } .flatMap { messageRepository.getMessages(student, it, folder, forceRefresh) }
@ -96,7 +101,7 @@ class MessageTabPresenter @Inject constructor(
.subscribe({ .subscribe({
Timber.i("Loading $folder message result: Success") Timber.i("Loading $folder message result: Success")
messages = it messages = it
onSearchQueryTextChange(lastSearchQuery) view?.updateData(getFilteredData(lastSearchQuery))
analytics.logEvent( analytics.logEvent(
"load_data", "load_data",
"type" to "messages", "type" to "messages",
@ -108,7 +113,6 @@ class MessageTabPresenter @Inject constructor(
errorHandler.dispatch(it) errorHandler.dispatch(it)
}) })
} }
}
private fun showErrorViewOnError(message: String, error: Throwable) { private fun showErrorViewOnError(message: String, error: Throwable) {
view?.run { view?.run {
@ -121,25 +125,36 @@ class MessageTabPresenter @Inject constructor(
} }
} }
@SuppressLint("DefaultLocale")
fun onSearchQueryTextChange(query: String) { fun onSearchQueryTextChange(query: String) {
if (query != searchQuery.toString())
searchQuery.onNext(query)
}
private fun initializeSearchStream() {
disposable.add(searchQuery
.debounce(250, TimeUnit.MILLISECONDS)
.map { query ->
lastSearchQuery = query lastSearchQuery = query
getFilteredData(query)
val lowerCaseQuery = query.toLowerCase()
val filteredList = mutableListOf<Message>()
messages.forEach {
if (lowerCaseQuery in it.subject.toLowerCase() ||
lowerCaseQuery in it.sender.toLowerCase() ||
lowerCaseQuery in it.recipient.toLowerCase() ||
lowerCaseQuery in it.date.toFormattedString()
) {
filteredList.add(it)
} }
.subscribeOn(schedulers.backgroundThread)
.observeOn(schedulers.mainThread)
.subscribe({
Timber.d("Applying filter. Full list: ${messages.size}, filtered: ${it.size}")
updateData(it)
}) { Timber.e(it) })
} }
Timber.d("Applying filter. Full list: ${messages.size}, filtered: ${filteredList.size}") private fun getFilteredData(query: String): List<Message> {
return if (query.trim().isEmpty()) {
updateData(filteredList) messages.sortedByDescending { it.date }
} else {
messages
.map { it to calculateMatchRatio(it, query) }
.sortedByDescending { it.second }
.filter { it.second > 5000 }
.map { it.first }
}
} }
private fun updateData(data: List<Message>) { private fun updateData(data: List<Message>) {
@ -151,4 +166,42 @@ class MessageTabPresenter @Inject constructor(
resetListPosition() resetListPosition()
} }
} }
private fun calculateMatchRatio(message: Message, query: String): Int {
val subjectRatio = FuzzySearch.tokenSortPartialRatio(
query.toLowerCase(Locale.getDefault()),
message.subject
)
val senderOrRecipientRatio = FuzzySearch.tokenSortPartialRatio(
query.toLowerCase(Locale.getDefault()),
if (message.sender.isNotEmpty()) message.sender.toLowerCase(Locale.getDefault())
else message.recipient.toLowerCase(Locale.getDefault())
)
val dateRatio = listOf(
FuzzySearch.ratio(
query.toLowerCase(Locale.getDefault()),
message.date.toFormattedString("dd.MM").toLowerCase(Locale.getDefault())
),
FuzzySearch.ratio(
query.toLowerCase(Locale.getDefault()),
message.date.toFormattedString("dd.MM.yyyy").toLowerCase(Locale.getDefault())
),
FuzzySearch.ratio(
query.toLowerCase(Locale.getDefault()),
message.date.toFormattedString("d MMMM").toLowerCase(Locale.getDefault())
),
FuzzySearch.ratio(
query.toLowerCase(Locale.getDefault()),
message.date.toFormattedString("d MMMM yyyy").toLowerCase(Locale.getDefault())
)
).max() ?: 0
return (subjectRatio.toDouble().pow(2)
+ senderOrRecipientRatio.toDouble().pow(2)
+ dateRatio.toDouble().pow(2) * 2
).toInt()
}
} }