diff --git a/core/detekt_baseline.xml b/core/detekt_baseline.xml index 2005344b5..74bf8b959 100644 --- a/core/detekt_baseline.xml +++ b/core/detekt_baseline.xml @@ -18,6 +18,7 @@ MagicNumber:ArticleCount.kt$ArticleCount$3 MagicNumber:CompatFindActionModeCallback.kt$CompatFindActionModeCallback$100 MagicNumber:DownloadItem.kt$DownloadItem$1000L + MagicNumber:SearchState.kt$SearchState$100 MagicNumber:DownloaderModule.kt$DownloaderModule$5 MagicNumber:FetchDownloadNotificationManager.kt$FetchDownloadNotificationManager$100 MagicNumber:FetchDownloadRequester.kt$10 diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/base/adapter/BaseDelegateAdapter.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/base/adapter/BaseDelegateAdapter.kt index cae0ea615..7b7162445 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/base/adapter/BaseDelegateAdapter.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/base/adapter/BaseDelegateAdapter.kt @@ -37,6 +37,13 @@ abstract class BaseDelegateAdapter( notifyDataSetChanged() } + // Function to add new data to the adapter + fun addData(newData: List) { + val startPosition = items.size + items = items.toMutableList().apply { addAll(newData) } + notifyItemRangeInserted(startPosition, newData.size) + } + override fun onCreateViewHolder( parent: ViewGroup, viewType: Int diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/reader/ZimFileReader.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/reader/ZimFileReader.kt index 6171342ee..2f9979288 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/reader/ZimFileReader.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/reader/ZimFileReader.kt @@ -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.UNINITIALISE_HTML 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.libkiwix.JNIKiwixException import org.kiwix.libzim.Archive @@ -143,19 +142,6 @@ class ZimFileReader constructor( null } - fun getSearchResultList(search: Search?): List { - val suggestionList = mutableListOf() - 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? = if (jniKiwixReader.hasEntryByTitle(title)) jniKiwixReader.getEntryByTitle(title).path diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/search/SearchFragment.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/search/SearchFragment.kt index fada01a05..8cfdb5959 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/search/SearchFragment.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/search/SearchFragment.kt @@ -65,6 +65,7 @@ import org.kiwix.kiwixmobile.core.utils.SimpleTextListener import javax.inject.Inject const val NAV_ARG_SEARCH_STRING = "searchString" +const val LAST_VISIBLE_ITEM = 5 class SearchFragment : BaseFragment() { @@ -76,6 +77,8 @@ class SearchFragment : BaseFragment() { private val searchViewModel by lazy { viewModel(viewModelFactory) } private var searchAdapter: SearchAdapter? = null + private var isLoading = false + private var searchState: SearchState? = null override fun inject(baseActivity: BaseActivity) { baseActivity.cachedComponent.inject(this) @@ -104,6 +107,22 @@ class SearchFragment : BaseFragment() { adapter = searchAdapter layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false) 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 { searchViewModel.effects.collect { it.invokeWith(this@SearchFragment.coreMainActivity) } @@ -111,6 +130,27 @@ class SearchFragment : BaseFragment() { 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() { activity?.onBackPressedDispatcher?.addCallback( viewLifecycleOwner, @@ -136,6 +176,7 @@ class SearchFragment : BaseFragment() { override fun onDestroyView() { super.onDestroyView() + searchState = null activity?.intent?.action = null searchView = null searchInTextMenuItem = null @@ -194,11 +235,13 @@ class SearchFragment : BaseFragment() { } private fun render(state: SearchState) { + searchState = state searchInTextMenuItem?.isVisible = state.searchOrigin == FromWebView searchInTextMenuItem?.isEnabled = state.searchTerm.isNotBlank() fragmentSearchBinding?.searchLoadingIndicator?.isShowing(state.isLoading) - fragmentSearchBinding?.searchNoResults?.isVisible = state.visibleResults.isEmpty() - searchAdapter?.items = state.visibleResults + fragmentSearchBinding?.searchNoResults?.isVisible = + state.getVisibleResults(0).isEmpty() + searchAdapter?.items = state.getVisibleResults(0) } private fun onItemClick(it: SearchListItem) { diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/search/viewmodel/SearchResultGenerator.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/search/viewmodel/SearchResultGenerator.kt index d6e4fd2fb..65d2b2980 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/search/viewmodel/SearchResultGenerator.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/search/viewmodel/SearchResultGenerator.kt @@ -22,14 +22,14 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.coroutines.yield import org.kiwix.kiwixmobile.core.reader.ZimFileReader -import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem +import org.kiwix.libzim.Search import javax.inject.Inject interface SearchResultGenerator { suspend fun generateSearchResults( searchTerm: String, zimFileReader: ZimFileReader? - ): List + ): Search? } class ZimSearchResultGenerator @Inject constructor() : SearchResultGenerator { @@ -37,7 +37,7 @@ class ZimSearchResultGenerator @Inject constructor() : SearchResultGenerator { override suspend fun generateSearchResults(searchTerm: String, zimFileReader: ZimFileReader?) = withContext(Dispatchers.IO) { if (searchTerm.isNotEmpty()) readResultsFromZim(searchTerm, zimFileReader) - else emptyList() + else null } private suspend fun readResultsFromZim( @@ -46,8 +46,4 @@ class ZimSearchResultGenerator @Inject constructor() : SearchResultGenerator { ) = reader.also { yield() } ?.searchSuggestions(searchTerm) - .also { yield() } - .run { - reader?.getSearchResultList(this) ?: emptyList() - } } diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/search/viewmodel/SearchState.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/search/viewmodel/SearchState.kt index c0df6bb9e..2559562b8 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/search/viewmodel/SearchState.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/search/viewmodel/SearchState.kt @@ -26,10 +26,26 @@ data class SearchState( val recentResults: List, val searchOrigin: SearchOrigin ) { - val visibleResults = when { - searchTerm.isNotEmpty() -> searchResultsWithTerm.results - else -> recentResults - } + fun getVisibleResults(startIndex: Int): List = + if (searchTerm.isEmpty()) { + 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() + while (searchIterator.hasNext()) { + val entry = searchIterator.next() + searchResults.add(SearchListItem.RecentSearchListItem(entry.title)) + } + searchResults + } ?: kotlin.run { + recentResults + } + } val isLoading = searchTerm != searchResultsWithTerm.searchTerm } diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/search/viewmodel/SearchViewModel.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/search/viewmodel/SearchViewModel.kt index 22a9c08cb..6b0b62c2e 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/search/viewmodel/SearchViewModel.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/search/viewmodel/SearchViewModel.kt @@ -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.ShowToast import org.kiwix.kiwixmobile.core.search.viewmodel.effects.StartSpeechInput +import org.kiwix.libzim.Search import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) @@ -69,7 +70,15 @@ class SearchViewModel @Inject constructor( ) : ViewModel() { private val initialState: SearchState = - SearchState("", SearchResultsWithTerm("", emptyList()), emptyList(), FromWebView) + SearchState( + "", + SearchResultsWithTerm( + "", + null + ), + emptyList(), + FromWebView + ) val state: MutableStateFlow = MutableStateFlow(initialState) private val _effects = Channel>() val effects = _effects.receiveAsFlow() @@ -165,4 +174,4 @@ class SearchViewModel @Inject constructor( } } -data class SearchResultsWithTerm(val searchTerm: String, val results: List) +data class SearchResultsWithTerm(val searchTerm: String, val search: Search?) diff --git a/core/src/test/java/org/kiwix/kiwixmobile/core/search/viewmodel/SearchStateTest.kt b/core/src/test/java/org/kiwix/kiwixmobile/core/search/viewmodel/SearchStateTest.kt index b34b70696..7a8f0b4fe 100644 --- a/core/src/test/java/org/kiwix/kiwixmobile/core/search/viewmodel/SearchStateTest.kt +++ b/core/src/test/java/org/kiwix/kiwixmobile/core/search/viewmodel/SearchStateTest.kt @@ -35,7 +35,7 @@ internal class SearchStateTest { SearchResultsWithTerm("", results), emptyList(), FromWebView - ).visibleResults + ).getVisibleResults(0) ).isEqualTo(results) } @@ -48,7 +48,7 @@ internal class SearchStateTest { SearchResultsWithTerm("", emptyList()), results, FromWebView - ).visibleResults + ).getVisibleResults(0) ).isEqualTo(results) }