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>(mapOf()) + private val filterQueryFlow = MutableStateFlow, List>>(Pair(listOf(), listOf())) + + data class SearchParams( + val query: Map, + val filters: Pair, List> + ) + + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) + val globalSearchResults: Flow> = + combine( + queryFlow.debounce(300).distinctUntilChanged(), + filterQueryFlow.debounce(300).distinctUntilChanged() + ) { query, filters -> + SearchParams(query, filters) + } + .flatMapLatest { params -> + if (params.query.isEmpty()) { + flowOf(PagingData.empty()) + } else { + repository.getGlobalSearchResults(params.query, params.filters) + } + } + .cachedIn(viewModelScope) + + fun updateSearchQuery(newQuery: Map) { + queryFlow.value = newQuery + } + + fun updateFilterQuery(newFilters: Pair, List>) { + filterQueryFlow.value = newFilters + } + + fun hasQuery(): Boolean = queryFlow.value.isNotEmpty() +} + + diff --git a/core/src/main/res/drawable/testpress_category_chip_background.xml b/core/src/main/res/drawable/testpress_category_chip_background.xml new file mode 100644 index 000000000..9c024a68b --- /dev/null +++ b/core/src/main/res/drawable/testpress_category_chip_background.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/testpress_category_chip_stock_colour.xml b/core/src/main/res/drawable/testpress_category_chip_stock_colour.xml new file mode 100644 index 000000000..b032a77cd --- /dev/null +++ b/core/src/main/res/drawable/testpress_category_chip_stock_colour.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/testpress_category_chip_text_colour.xml b/core/src/main/res/drawable/testpress_category_chip_text_colour.xml new file mode 100644 index 000000000..205fe916b --- /dev/null +++ b/core/src/main/res/drawable/testpress_category_chip_text_colour.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/core/src/main/res/layout/global_search_activity_layout.xml b/core/src/main/res/layout/global_search_activity_layout.xml new file mode 100644 index 000000000..53b108a3a --- /dev/null +++ b/core/src/main/res/layout/global_search_activity_layout.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/core/src/main/res/layout/global_search_fragment_layout.xml b/core/src/main/res/layout/global_search_fragment_layout.xml new file mode 100644 index 000000000..480efe0c5 --- /dev/null +++ b/core/src/main/res/layout/global_search_fragment_layout.xml @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/core/src/main/res/layout/search_result_item.xml b/core/src/main/res/layout/search_result_item.xml new file mode 100644 index 000000000..ccd6bd9d8 --- /dev/null +++ b/core/src/main/res/layout/search_result_item.xml @@ -0,0 +1,43 @@ + + + + + + + + + \ No newline at end of file diff --git a/core/src/main/res/layout/testpress_base_list_footer_adapter.xml b/core/src/main/res/layout/testpress_base_list_footer_adapter.xml new file mode 100644 index 000000000..dafa0b056 --- /dev/null +++ b/core/src/main/res/layout/testpress_base_list_footer_adapter.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/core/src/main/res/values/styles.xml b/core/src/main/res/values/styles.xml index d88a965f1..e24ce5f51 100644 --- a/core/src/main/res/values/styles.xml +++ b/core/src/main/res/values/styles.xml @@ -98,4 +98,19 @@ actionNext + + \ No newline at end of file diff --git a/samples/src/main/java/in/testpress/samples/course/CourseSampleActivity.java b/samples/src/main/java/in/testpress/samples/course/CourseSampleActivity.java index fc49965be..e3a9f7329 100644 --- a/samples/src/main/java/in/testpress/samples/course/CourseSampleActivity.java +++ b/samples/src/main/java/in/testpress/samples/course/CourseSampleActivity.java @@ -13,6 +13,7 @@ import in.testpress.samples.BaseToolBarActivity; import in.testpress.samples.R; import in.testpress.samples.core.TestpressCoreSampleActivity; +import in.testpress.ui.GlobalSearchActivity; import in.testpress.ui.WebViewWithSSOActivity; import in.testpress.ui.DiscussionActivity; //import in.testpress.ui.WebViewWithSSOActivity; @@ -182,6 +183,13 @@ public void onClick(View view) { openSampleOfflineExamActivity(); } }); + + findViewById(R.id.global_search_button).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + openGlobalSearchActivity(); + } + }); } @SuppressWarnings("ConstantConditions") @@ -309,6 +317,10 @@ private void openSampleOfflineExamActivity() { startActivity(new Intent(this,OfflineExamSampleActivity.class)); } + private void openGlobalSearchActivity() { + startActivity(new Intent(this, GlobalSearchActivity.class)); + } + @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); diff --git a/samples/src/main/res/layout/activity_open_in.xml b/samples/src/main/res/layout/activity_open_in.xml index 7983aadfd..f59f75790 100644 --- a/samples/src/main/res/layout/activity_open_in.xml +++ b/samples/src/main/res/layout/activity_open_in.xml @@ -96,6 +96,11 @@ android:text="Sample Offline Exam" android:id="@+id/sample_offline_exam_download" /> + +