diff --git a/core/src/main/AndroidManifest.xml b/core/src/main/AndroidManifest.xml
index 32cdebc0b..2c0e060a5 100644
--- a/core/src/main/AndroidManifest.xml
+++ b/core/src/main/AndroidManifest.xml
@@ -35,6 +35,10 @@
android:name=".ui.WebViewWithSSOActivity"
android:configChanges="orientation|keyboardHidden|screenSize" />
+
+
diff --git a/core/src/main/java/in/testpress/models/Search.kt b/core/src/main/java/in/testpress/models/Search.kt
new file mode 100644
index 000000000..4e513a7c6
--- /dev/null
+++ b/core/src/main/java/in/testpress/models/Search.kt
@@ -0,0 +1,18 @@
+package `in`.testpress.models
+
+data class SearchApiResponse(
+ val results: List,
+ val nextPage: Int?
+)
+
+data class SearchResult(
+ val title: String?,
+ val highlight: Highlight?,
+ val active: Boolean?,
+ val type: String?,
+ val id: Int?
+)
+
+data class Highlight(
+ val title: String?
+)
\ No newline at end of file
diff --git a/core/src/main/java/in/testpress/network/TestpressAPIService.kt b/core/src/main/java/in/testpress/network/TestpressAPIService.kt
index a38370707..81b7ae40e 100644
--- a/core/src/main/java/in/testpress/network/TestpressAPIService.kt
+++ b/core/src/main/java/in/testpress/network/TestpressAPIService.kt
@@ -1,19 +1,18 @@
package `in`.testpress.network
import `in`.testpress.core.TestpressSdk
-import `in`.testpress.models.NetworkCategory
-import `in`.testpress.models.NetworkDiscussionThreadAnswer
-import `in`.testpress.models.NetworkForum
-import `in`.testpress.models.TestpressApiResponse
import android.content.Context
+import `in`.testpress.models.*
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Path
+import retrofit2.http.Query
import retrofit2.http.QueryMap
const val URL_FORUMS_FRAG = "api/v2.5/discussions/"
const val FORUM_CATEGORIES_URL = "api/v2.3/posts/categories/"
+const val GLOBAL_SEARCH_PATH = "/api/v2.5/global_search/search_results/"
@JvmSuppressWildcards
interface TestpressAPIService {
@@ -31,6 +30,13 @@ interface TestpressAPIService {
fun getDiscussionAnswer(
@Path(value = "discussion_id", encoded = true) discussionId: Long
): RetrofitCall
+
+ @GET(GLOBAL_SEARCH_PATH)
+ suspend fun getGlobalSearch(
+ @QueryMap queryParams: Map,
+ @Query("param") filterParams: List,
+ @Query("chaptercontent_content_type") filterContentTypes: List
+ ): SearchApiResponse
}
open class APIClient(context: Context): TestpressApiClient(context, TestpressSdk.getTestpressSession(context)) {
@@ -48,4 +54,8 @@ open class APIClient(context: Context): TestpressApiClient(context, TestpressSdk
fun getDiscussionAnswer(discussionId: Long): RetrofitCall {
return getService().getDiscussionAnswer(discussionId)
}
+
+ suspend fun getGlobalSearch(queryParams: Map, filterQueryParams: Pair,List>): SearchApiResponse {
+ return getService().getGlobalSearch(queryParams, filterQueryParams.first,filterQueryParams.second)
+ }
}
\ No newline at end of file
diff --git a/core/src/main/java/in/testpress/repository/GlobalSearchPagingSource.kt b/core/src/main/java/in/testpress/repository/GlobalSearchPagingSource.kt
new file mode 100644
index 000000000..dda42a1c7
--- /dev/null
+++ b/core/src/main/java/in/testpress/repository/GlobalSearchPagingSource.kt
@@ -0,0 +1,37 @@
+package `in`.testpress.repository
+
+import androidx.paging.PagingSource
+import androidx.paging.PagingState
+import `in`.testpress.models.SearchResult
+import `in`.testpress.network.APIClient
+
+class GlobalSearchPagingSource(
+ private val apiClient: APIClient,
+ private val queryParams: Map,
+ private val filterQueryParams: Pair, List>
+) : PagingSource() {
+
+ override suspend fun load(params: LoadParams): LoadResult {
+ return try {
+ val mutableMap = queryParams.toMutableMap()
+ mutableMap["page"] = params.key ?: 1
+ mutableMap["size"] = 20
+ val response = apiClient.getGlobalSearch(mutableMap, filterQueryParams)
+
+ LoadResult.Page(
+ data = response.results,
+ prevKey = null,
+ nextKey = response.nextPage
+ )
+ } catch (e: Exception) {
+ LoadResult.Error(e)
+ }
+ }
+
+ override fun getRefreshKey(state: PagingState): Int? {
+ return state.anchorPosition?.let { anchorPosition ->
+ val anchorPage = state.closestPageToPosition(anchorPosition)
+ anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
+ }
+ }
+}
diff --git a/core/src/main/java/in/testpress/repository/GlobalSearchRepository.kt b/core/src/main/java/in/testpress/repository/GlobalSearchRepository.kt
new file mode 100644
index 000000000..1cb74f070
--- /dev/null
+++ b/core/src/main/java/in/testpress/repository/GlobalSearchRepository.kt
@@ -0,0 +1,18 @@
+package `in`.testpress.repository
+
+import androidx.paging.Pager
+import androidx.paging.PagingConfig
+import androidx.paging.PagingData
+import `in`.testpress.models.SearchResult
+import `in`.testpress.network.APIClient
+import kotlinx.coroutines.flow.Flow
+
+class GlobalSearchRepository(private val apiClient: APIClient) {
+
+ fun getGlobalSearchResults(query: Map, filterQueryParams: Pair, List>): Flow> {
+ return Pager(
+ config = PagingConfig(pageSize = 15),
+ pagingSourceFactory = { GlobalSearchPagingSource(apiClient, query, filterQueryParams) }
+ ).flow
+ }
+}
diff --git a/core/src/main/java/in/testpress/ui/GlobalSearchActivity.kt b/core/src/main/java/in/testpress/ui/GlobalSearchActivity.kt
new file mode 100644
index 000000000..5b60aa4ad
--- /dev/null
+++ b/core/src/main/java/in/testpress/ui/GlobalSearchActivity.kt
@@ -0,0 +1,18 @@
+package `in`.testpress.ui
+
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import `in`.testpress.R
+import `in`.testpress.ui.fragments.GlobalSearchFragment
+
+class GlobalSearchActivity : AppCompatActivity() {
+
+ public override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.global_search_activity_layout)
+ val fragment = GlobalSearchFragment()
+ supportFragmentManager.beginTransaction()
+ .replace(R.id.global_search_fragment_container, fragment).commitAllowingStateLoss()
+ }
+
+}
\ No newline at end of file
diff --git a/core/src/main/java/in/testpress/ui/adapter/BaseListFooterAdapter.kt b/core/src/main/java/in/testpress/ui/adapter/BaseListFooterAdapter.kt
new file mode 100644
index 000000000..e66231728
--- /dev/null
+++ b/core/src/main/java/in/testpress/ui/adapter/BaseListFooterAdapter.kt
@@ -0,0 +1,60 @@
+package `in`.testpress.ui.adapter
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.core.view.isVisible
+import androidx.paging.LoadState
+import androidx.paging.LoadStateAdapter
+import androidx.recyclerview.widget.RecyclerView
+import `in`.testpress.R
+import `in`.testpress.databinding.TestpressBaseListFooterAdapterBinding
+
+class BaseListFooterAdapter(private val retry: () -> Unit) :
+ LoadStateAdapter() {
+ override fun onBindViewHolder(holder: BaseListFooterViewHolder, loadState: LoadState) {
+ holder.bind(loadState)
+ }
+
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ loadState: LoadState
+ ): BaseListFooterViewHolder {
+ return BaseListFooterViewHolder.create(parent, retry)
+ }
+
+ class BaseListFooterViewHolder(
+ private val binding: TestpressBaseListFooterAdapterBinding,
+ retry: () -> Unit
+ ) : RecyclerView.ViewHolder(binding.root) {
+
+ init {
+ binding.retryButton.setOnClickListener { retry.invoke() }
+ }
+
+ fun bind(loadState: LoadState) {
+ if (loadState is LoadState.Error) {
+ showErrorMessage(loadState)
+ }
+ binding.progressBar.isVisible = loadState is LoadState.Loading
+ binding.retryButton.isVisible = loadState is LoadState.Error
+ binding.errorMessageContainer.isVisible = loadState is LoadState.Error
+ }
+
+ private fun showErrorMessage(loadState: LoadState.Error) {
+ if (loadState.error.localizedMessage?.contains("404") == true) {
+ binding.emptyTitle.text = "Content Not Found"
+ binding.emptyDescription.text = "Content Not Found, Please try after some time"
+ }
+ }
+
+ companion object {
+ fun create(parent: ViewGroup, retry: () -> Unit): BaseListFooterViewHolder {
+ val view = LayoutInflater.from(parent.context)
+ .inflate(R.layout.testpress_base_list_footer_adapter, parent, false)
+ val binding = TestpressBaseListFooterAdapterBinding.bind(view)
+ return BaseListFooterViewHolder(binding, retry)
+ }
+ }
+ }
+}
+
diff --git a/core/src/main/java/in/testpress/ui/adapter/GlobalSearchAdapter.kt b/core/src/main/java/in/testpress/ui/adapter/GlobalSearchAdapter.kt
new file mode 100644
index 000000000..4b54b5817
--- /dev/null
+++ b/core/src/main/java/in/testpress/ui/adapter/GlobalSearchAdapter.kt
@@ -0,0 +1,61 @@
+package `in`.testpress.ui.adapter
+
+import android.text.Html
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.paging.PagingDataAdapter
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.RecyclerView
+import `in`.testpress.databinding.SearchResultItemBinding
+import `in`.testpress.models.SearchResult
+
+class GlobalSearchAdapter :
+ PagingDataAdapter(ARTICLE_DIFF_CALLBACK) {
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SearchResultHolder =
+ SearchResultHolder(
+ SearchResultItemBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false,
+ )
+ )
+
+ override fun onBindViewHolder(holder: SearchResultHolder, position: Int) {
+ val tile = getItem(position)
+ if (tile != null) {
+ holder.bind(tile)
+ }
+ }
+
+ class SearchResultHolder(
+ private val binding: SearchResultItemBinding
+ ) : RecyclerView.ViewHolder(binding.root) {
+
+ fun bind(searchResult: SearchResult) {
+ binding.apply {
+ val title = convertHighlightToInlineStyle(searchResult.highlight?.title ?: "")
+ binding.title.text = Html.fromHtml(title)
+ binding.type.text = searchResult.type
+ binding.active.text = searchResult.active.toString()
+ }
+ }
+
+ private fun convertHighlightToInlineStyle(htmlResponse: String): String {
+ return htmlResponse.replace(
+ "class=\'highlight\'",
+ "style=\'background-color: yellow;\'"
+ )
+ }
+ }
+
+ companion object {
+ private val ARTICLE_DIFF_CALLBACK = object : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: SearchResult, newItem: SearchResult): Boolean =
+ oldItem.id == newItem.id
+
+ override fun areContentsTheSame(oldItem: SearchResult, newItem: SearchResult): Boolean =
+ oldItem == newItem
+ }
+ }
+}
diff --git a/core/src/main/java/in/testpress/ui/fragments/GlobalSearchFragment.kt b/core/src/main/java/in/testpress/ui/fragments/GlobalSearchFragment.kt
new file mode 100644
index 000000000..6fffa8525
--- /dev/null
+++ b/core/src/main/java/in/testpress/ui/fragments/GlobalSearchFragment.kt
@@ -0,0 +1,178 @@
+package `in`.testpress.ui.fragments
+
+import android.os.Bundle
+import android.text.*
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.view.isVisible
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.lifecycleScope
+import androidx.paging.LoadState
+import androidx.recyclerview.widget.DividerItemDecoration
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.google.android.material.chip.Chip
+import `in`.testpress.R
+import `in`.testpress.databinding.GlobalSearchFragmentLayoutBinding
+import `in`.testpress.network.APIClient
+import `in`.testpress.repository.GlobalSearchRepository
+import `in`.testpress.ui.adapter.BaseListFooterAdapter
+import `in`.testpress.ui.adapter.GlobalSearchAdapter
+import `in`.testpress.ui.viewmodel.GlobalSearchViewModel
+import `in`.testpress.util.InternetConnectivityChecker
+import kotlinx.coroutines.flow.collectLatest
+
+class GlobalSearchFragment : Fragment() {
+
+ private var _binding: GlobalSearchFragmentLayoutBinding? = null
+ private val binding get() = _binding!!
+ private lateinit var viewModel: GlobalSearchViewModel
+ private lateinit var adapter: GlobalSearchAdapter
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ initializeViewModel()
+ }
+
+ private fun initializeViewModel() {
+ viewModel = ViewModelProvider(requireActivity(), object : ViewModelProvider.Factory {
+ override fun create(modelClass: Class): T {
+ return GlobalSearchViewModel(
+ GlobalSearchRepository(
+ APIClient(requireContext())
+ )
+ ) as T
+ }
+ }).get(GlobalSearchViewModel::class.java)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ _binding = GlobalSearchFragmentLayoutBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ binding.resultsList.apply {
+ layoutManager = LinearLayoutManager(requireContext())
+ addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
+ adapter = this@GlobalSearchFragment.getAdapter()
+ }
+ binding.resultsList.adapter = adapter.withLoadStateFooter(
+ BaseListFooterAdapter {
+ adapter.retry()
+ }
+ )
+
+ lifecycleScope.launchWhenCreated {
+ adapter.loadStateFlow.collectLatest { loadStates ->
+
+ binding.loadingView.isVisible = loadStates.refresh is LoadState.Loading && adapter.itemCount == 0
+
+ binding.noDataView.visibility = if (loadStates.refresh is LoadState.NotLoading && adapter.itemCount == 0) View.VISIBLE else View.GONE
+ if (viewModel.hasQuery() && binding.noDataView.isVisible){
+ binding.noDataView.text = "No data found"
+ } else {
+ binding.noDataView.text = "Search or type a command"
+ }
+
+ if (loadStates.refresh is LoadState.Error){
+ if (isNetworkError(loadStates.refresh as LoadState.Error)){
+ binding.noDataView.isVisible = true
+ binding.noDataView.text = "Please check your internet connection"
+ }
+ }
+ }
+ }
+
+ // Listen to global query text changes
+ binding.searchBar.addTextChangedListener(object : TextWatcher {
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
+ s?.let {
+ if (it.isNotEmpty()) {
+ viewModel.updateSearchQuery(mapOf("q" to it.toString()))
+ } else {
+ viewModel.updateSearchQuery(mapOf())
+ }
+ }
+ }
+
+ override fun afterTextChanged(s: Editable?) {}
+ })
+
+ for (i in 0 until binding.filterChipGroup.childCount) {
+ val chip = binding.filterChipGroup.getChildAt(i) as? Chip
+ chip?.setOnCheckedChangeListener { buttonView, isChecked ->
+ search()
+ }
+ }
+ }
+
+ fun isNetworkError(error: LoadState.Error): Boolean {
+ val networkErrorMessage =
+ error.error.localizedMessage?.contains("Unable to resolve host", true) ?: false
+ return networkErrorMessage && !InternetConnectivityChecker.isConnected(requireContext())
+ }
+
+ private fun getAdapter(): GlobalSearchAdapter {
+ adapter = GlobalSearchAdapter()
+ lifecycleScope.launchWhenCreated {
+ viewModel.globalSearchResults.collectLatest {
+ adapter.submitData(it)
+ }
+ }
+ return adapter
+ }
+
+ private fun search() {
+ val selectedIds = mutableListOf()
+ for (i in 0 until binding.filterChipGroup.childCount) {
+ val chip = binding.filterChipGroup.getChildAt(i) as? Chip
+ if (chip?.isChecked == true) {
+ selectedIds.add(chip.id)
+ }
+ }
+ val pair = Pair(getFilterParams(selectedIds), getFilterForContentType(selectedIds))
+ viewModel.updateFilterQuery(pair)
+ }
+
+ private fun getFilterParams(ids: List): List {
+ val idToParamMap = mapOf(
+ R.id.course to "course",
+ R.id.chapter to "chapter",
+ R.id.content to "chaptercontent",
+ R.id.live_stream to "live stream",
+ R.id.discussion to "forumthread",
+ R.id.discussion_category to "forumthreadcategory",
+ R.id.post to "post",
+ R.id.product to "product",
+ R.id.doubts to "ticket"
+ )
+ return ids.mapNotNull { idToParamMap[it] }
+ }
+
+ private fun getFilterForContentType(ids: List): List {
+ val idToParamMap = mapOf(
+ R.id.exam to "Exam",
+ R.id.video to "Video",
+ R.id.quiz to "Quiz",
+ R.id.attachment to "Attachment",
+ R.id.notes to "Notes",
+ R.id.video_conferences to "VideoConference",
+ )
+ return ids.mapNotNull { idToParamMap[it] }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
\ No newline at end of file
diff --git a/core/src/main/java/in/testpress/ui/viewmodel/GlobalSearchViewModel.kt b/core/src/main/java/in/testpress/ui/viewmodel/GlobalSearchViewModel.kt
new file mode 100644
index 000000000..ffd7d7bcc
--- /dev/null
+++ b/core/src/main/java/in/testpress/ui/viewmodel/GlobalSearchViewModel.kt
@@ -0,0 +1,51 @@
+package `in`.testpress.ui.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.paging.PagingData
+import androidx.paging.cachedIn
+import `in`.testpress.models.SearchResult
+import `in`.testpress.repository.GlobalSearchRepository
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.*
+
+class GlobalSearchViewModel(private val repository: GlobalSearchRepository) : ViewModel() {
+
+ private val queryFlow = MutableStateFlow