Introduced an endless suggestion list in search.

* With the integration of libkiwix12, we now receive full results based on the search term. Previously, we were loading the entire list, but we have now implemented pagination to enhance the search functionality.
* The libkiwix provides us with a Search object, which enables us to obtain the suggestion list using start and end indices. Consequently, we have modified our `ZimSearchResultGenerator` code to return the `Search` object instead of a list.
* To accommodate the changes, we have updated the return type of `SearchResultGenerator` to a nullable Search. This change is necessary because we initialize the `SearchState` when the search is initialized in `SearchViewModel`, and initially, we do not have the Search object available. The nullable return type allows us to pass the Search object when it becomes available.
This commit is contained in:
MohitMali 2023-08-01 17:55:01 +05:30
parent a99a988a98
commit f03830c62f
8 changed files with 89 additions and 31 deletions

View File

@ -18,6 +18,7 @@
<ID>MagicNumber:ArticleCount.kt$ArticleCount$3</ID> <ID>MagicNumber:ArticleCount.kt$ArticleCount$3</ID>
<ID>MagicNumber:CompatFindActionModeCallback.kt$CompatFindActionModeCallback$100</ID> <ID>MagicNumber:CompatFindActionModeCallback.kt$CompatFindActionModeCallback$100</ID>
<ID>MagicNumber:DownloadItem.kt$DownloadItem$1000L</ID> <ID>MagicNumber:DownloadItem.kt$DownloadItem$1000L</ID>
<ID>MagicNumber:SearchState.kt$SearchState$100</ID>
<ID>MagicNumber:DownloaderModule.kt$DownloaderModule$5</ID> <ID>MagicNumber:DownloaderModule.kt$DownloaderModule$5</ID>
<ID>MagicNumber:FetchDownloadNotificationManager.kt$FetchDownloadNotificationManager$100</ID> <ID>MagicNumber:FetchDownloadNotificationManager.kt$FetchDownloadNotificationManager$100</ID>
<ID>MagicNumber:FetchDownloadRequester.kt$10</ID> <ID>MagicNumber:FetchDownloadRequester.kt$10</ID>

View File

@ -37,6 +37,13 @@ abstract class BaseDelegateAdapter<ITEM>(
notifyDataSetChanged() notifyDataSetChanged()
} }
// Function to add new data to the adapter
fun addData(newData: List<ITEM>) {
val startPosition = items.size
items = items.toMutableList().apply { addAll(newData) }
notifyItemRangeInserted(startPosition, newData.size)
}
override fun onCreateViewHolder( override fun onCreateViewHolder(
parent: ViewGroup, parent: ViewGroup,
viewType: Int viewType: Int

View File

@ -32,7 +32,6 @@ import org.kiwix.kiwixmobile.core.entity.LibraryNetworkEntity.Book
import org.kiwix.kiwixmobile.core.main.UNINITIALISER_ADDRESS import org.kiwix.kiwixmobile.core.main.UNINITIALISER_ADDRESS
import org.kiwix.kiwixmobile.core.main.UNINITIALISE_HTML import org.kiwix.kiwixmobile.core.main.UNINITIALISE_HTML
import org.kiwix.kiwixmobile.core.reader.ZimFileReader.Companion.CONTENT_PREFIX import org.kiwix.kiwixmobile.core.reader.ZimFileReader.Companion.CONTENT_PREFIX
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.ZimSearchResultListItem
import org.kiwix.kiwixmobile.core.utils.files.FileUtils import org.kiwix.kiwixmobile.core.utils.files.FileUtils
import org.kiwix.libkiwix.JNIKiwixException import org.kiwix.libkiwix.JNIKiwixException
import org.kiwix.libzim.Archive import org.kiwix.libzim.Archive
@ -143,19 +142,6 @@ class ZimFileReader constructor(
null null
} }
fun getSearchResultList(search: Search?): List<ZimSearchResultListItem> {
val suggestionList = mutableListOf<ZimSearchResultListItem>()
val suggestionIterator =
search?.getResults(0, search.estimatedMatches.toInt())
suggestionIterator?.let {
while (it.hasNext()) {
val entry = it.next()
suggestionList.add(ZimSearchResultListItem(entry.title))
}
}
return suggestionList
}
fun getPageUrlFrom(title: String): String? = fun getPageUrlFrom(title: String): String? =
if (jniKiwixReader.hasEntryByTitle(title)) if (jniKiwixReader.hasEntryByTitle(title))
jniKiwixReader.getEntryByTitle(title).path jniKiwixReader.getEntryByTitle(title).path

View File

@ -65,6 +65,7 @@ import org.kiwix.kiwixmobile.core.utils.SimpleTextListener
import javax.inject.Inject import javax.inject.Inject
const val NAV_ARG_SEARCH_STRING = "searchString" const val NAV_ARG_SEARCH_STRING = "searchString"
const val LAST_VISIBLE_ITEM = 5
class SearchFragment : BaseFragment() { class SearchFragment : BaseFragment() {
@ -76,6 +77,8 @@ class SearchFragment : BaseFragment() {
private val searchViewModel by lazy { viewModel<SearchViewModel>(viewModelFactory) } private val searchViewModel by lazy { viewModel<SearchViewModel>(viewModelFactory) }
private var searchAdapter: SearchAdapter? = null private var searchAdapter: SearchAdapter? = null
private var isLoading = false
private var searchState: SearchState? = null
override fun inject(baseActivity: BaseActivity) { override fun inject(baseActivity: BaseActivity) {
baseActivity.cachedComponent.inject(this) baseActivity.cachedComponent.inject(this)
@ -104,6 +107,22 @@ class SearchFragment : BaseFragment() {
adapter = searchAdapter adapter = searchAdapter
layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false) layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false)
setHasFixedSize(true) setHasFixedSize(true)
// Add scroll listener to detect when the last item is reached
addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
val totalItemCount = layoutManager.itemCount
val lastVisibleItem = layoutManager.findLastVisibleItemPosition()
// Check if the user is about to reach the last item
if (!isLoading && totalItemCount <= (lastVisibleItem + LAST_VISIBLE_ITEM)) {
// Load more data when the last item is almost visible
loadMoreSearchResult()
}
}
})
} }
lifecycleScope.launchWhenCreated { lifecycleScope.launchWhenCreated {
searchViewModel.effects.collect { it.invokeWith(this@SearchFragment.coreMainActivity) } searchViewModel.effects.collect { it.invokeWith(this@SearchFragment.coreMainActivity) }
@ -111,6 +130,27 @@ class SearchFragment : BaseFragment() {
handleBackPress() handleBackPress()
} }
private fun loadMoreSearchResult() {
if (isLoading) {
return
}
val startIndex = searchAdapter?.itemCount ?: 0
// Set isLoading flag to true to prevent multiple load requests at once
isLoading = true
val fetchMoreSearchResults = searchState?.getVisibleResults(startIndex)
if (fetchMoreSearchResults?.isNotEmpty() == true) {
// Check if there is no duplicate entry, this is specially added for searched history items.
val nonDuplicateResults = fetchMoreSearchResults.filter { newItem ->
searchAdapter?.items?.any { it != newItem } == true
}
// Append new data (non-duplicate items) to the existing dataset
searchAdapter?.addData(nonDuplicateResults)
isLoading = false // Set isLoading to false when data is loaded successfully
}
}
private fun handleBackPress() { private fun handleBackPress() {
activity?.onBackPressedDispatcher?.addCallback( activity?.onBackPressedDispatcher?.addCallback(
viewLifecycleOwner, viewLifecycleOwner,
@ -136,6 +176,7 @@ class SearchFragment : BaseFragment() {
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
searchState = null
activity?.intent?.action = null activity?.intent?.action = null
searchView = null searchView = null
searchInTextMenuItem = null searchInTextMenuItem = null
@ -194,11 +235,13 @@ class SearchFragment : BaseFragment() {
} }
private fun render(state: SearchState) { private fun render(state: SearchState) {
searchState = state
searchInTextMenuItem?.isVisible = state.searchOrigin == FromWebView searchInTextMenuItem?.isVisible = state.searchOrigin == FromWebView
searchInTextMenuItem?.isEnabled = state.searchTerm.isNotBlank() searchInTextMenuItem?.isEnabled = state.searchTerm.isNotBlank()
fragmentSearchBinding?.searchLoadingIndicator?.isShowing(state.isLoading) fragmentSearchBinding?.searchLoadingIndicator?.isShowing(state.isLoading)
fragmentSearchBinding?.searchNoResults?.isVisible = state.visibleResults.isEmpty() fragmentSearchBinding?.searchNoResults?.isVisible =
searchAdapter?.items = state.visibleResults state.getVisibleResults(0).isEmpty()
searchAdapter?.items = state.getVisibleResults(0)
} }
private fun onItemClick(it: SearchListItem) { private fun onItemClick(it: SearchListItem) {

View File

@ -22,14 +22,14 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import org.kiwix.kiwixmobile.core.reader.ZimFileReader import org.kiwix.kiwixmobile.core.reader.ZimFileReader
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem import org.kiwix.libzim.Search
import javax.inject.Inject import javax.inject.Inject
interface SearchResultGenerator { interface SearchResultGenerator {
suspend fun generateSearchResults( suspend fun generateSearchResults(
searchTerm: String, searchTerm: String,
zimFileReader: ZimFileReader? zimFileReader: ZimFileReader?
): List<SearchListItem> ): Search?
} }
class ZimSearchResultGenerator @Inject constructor() : SearchResultGenerator { class ZimSearchResultGenerator @Inject constructor() : SearchResultGenerator {
@ -37,7 +37,7 @@ class ZimSearchResultGenerator @Inject constructor() : SearchResultGenerator {
override suspend fun generateSearchResults(searchTerm: String, zimFileReader: ZimFileReader?) = override suspend fun generateSearchResults(searchTerm: String, zimFileReader: ZimFileReader?) =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
if (searchTerm.isNotEmpty()) readResultsFromZim(searchTerm, zimFileReader) if (searchTerm.isNotEmpty()) readResultsFromZim(searchTerm, zimFileReader)
else emptyList() else null
} }
private suspend fun readResultsFromZim( private suspend fun readResultsFromZim(
@ -46,8 +46,4 @@ class ZimSearchResultGenerator @Inject constructor() : SearchResultGenerator {
) = ) =
reader.also { yield() } reader.also { yield() }
?.searchSuggestions(searchTerm) ?.searchSuggestions(searchTerm)
.also { yield() }
.run {
reader?.getSearchResultList(this) ?: emptyList()
}
} }

View File

@ -26,9 +26,25 @@ data class SearchState(
val recentResults: List<SearchListItem.RecentSearchListItem>, val recentResults: List<SearchListItem.RecentSearchListItem>,
val searchOrigin: SearchOrigin val searchOrigin: SearchOrigin
) { ) {
val visibleResults = when { fun getVisibleResults(startIndex: Int): List<SearchListItem.RecentSearchListItem> =
searchTerm.isNotEmpty() -> searchResultsWithTerm.results if (searchTerm.isEmpty()) {
else -> recentResults recentResults
} else {
searchResultsWithTerm.search?.let {
val maximumResults = it.estimatedMatches
val safeEndIndex =
if ((startIndex + 100) < maximumResults) startIndex + 100 else maximumResults
val searchIterator =
it.getResults(startIndex, safeEndIndex.toInt())
val searchResults = mutableListOf<SearchListItem.RecentSearchListItem>()
while (searchIterator.hasNext()) {
val entry = searchIterator.next()
searchResults.add(SearchListItem.RecentSearchListItem(entry.title))
}
searchResults
} ?: kotlin.run {
recentResults
}
} }
val isLoading = searchTerm != searchResultsWithTerm.searchTerm val isLoading = searchTerm != searchResultsWithTerm.searchTerm

View File

@ -59,6 +59,7 @@ import org.kiwix.kiwixmobile.core.search.viewmodel.effects.SearchInPreviousScree
import org.kiwix.kiwixmobile.core.search.viewmodel.effects.ShowDeleteSearchDialog import org.kiwix.kiwixmobile.core.search.viewmodel.effects.ShowDeleteSearchDialog
import org.kiwix.kiwixmobile.core.search.viewmodel.effects.ShowToast import org.kiwix.kiwixmobile.core.search.viewmodel.effects.ShowToast
import org.kiwix.kiwixmobile.core.search.viewmodel.effects.StartSpeechInput import org.kiwix.kiwixmobile.core.search.viewmodel.effects.StartSpeechInput
import org.kiwix.libzim.Search
import javax.inject.Inject import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@ -69,7 +70,15 @@ class SearchViewModel @Inject constructor(
) : ViewModel() { ) : ViewModel() {
private val initialState: SearchState = private val initialState: SearchState =
SearchState("", SearchResultsWithTerm("", emptyList()), emptyList(), FromWebView) SearchState(
"",
SearchResultsWithTerm(
"",
null
),
emptyList(),
FromWebView
)
val state: MutableStateFlow<SearchState> = MutableStateFlow(initialState) val state: MutableStateFlow<SearchState> = MutableStateFlow(initialState)
private val _effects = Channel<SideEffect<*>>() private val _effects = Channel<SideEffect<*>>()
val effects = _effects.receiveAsFlow() val effects = _effects.receiveAsFlow()
@ -165,4 +174,4 @@ class SearchViewModel @Inject constructor(
} }
} }
data class SearchResultsWithTerm(val searchTerm: String, val results: List<SearchListItem>) data class SearchResultsWithTerm(val searchTerm: String, val search: Search?)

View File

@ -35,7 +35,7 @@ internal class SearchStateTest {
SearchResultsWithTerm("", results), SearchResultsWithTerm("", results),
emptyList(), emptyList(),
FromWebView FromWebView
).visibleResults ).getVisibleResults(0)
).isEqualTo(results) ).isEqualTo(results)
} }
@ -48,7 +48,7 @@ internal class SearchStateTest {
SearchResultsWithTerm("", emptyList()), SearchResultsWithTerm("", emptyList()),
results, results,
FromWebView FromWebView
).visibleResults ).getVisibleResults(0)
).isEqualTo(results) ).isEqualTo(results)
} }