#3760 Convert SearchCategoriesFragment to use Pagination - extract functionality of pagination to base classes - add category pagination

This commit is contained in:
Sean Mac Gillicuddy 2020-05-25 13:01:27 +01:00
parent ec53377035
commit 6368e500a8
30 changed files with 684 additions and 882 deletions

View file

@ -7,13 +7,6 @@
<option name="TAB_SIZE" value="2" />
</value>
</option>
<AndroidXmlCodeStyleSettings>
<option name="LAYOUT_SETTINGS">
<value>
<option name="INSERT_BLANK_LINE_BEFORE_TAG" value="false" />
</value>
</option>
</AndroidXmlCodeStyleSettings>
<JSCodeStyleSettings>
<option name="INDENT_CHAINED_CALLS" value="false" />
</JSCodeStyleSettings>
@ -248,6 +241,28 @@
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
@ -275,211 +290,12 @@
<rule>
<match>
<AND>
<NAME>.*:.*Style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_width</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_height</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_weight</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_margin</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_marginTop</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_marginBottom</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_marginStart</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_marginEnd</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_marginLeft</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_marginRight</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:padding</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:paddingTop</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:paddingBottom</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:paddingStart</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:paddingEnd</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:paddingLeft</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:paddingRight</NAME>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
@ -487,39 +303,7 @@
<match>
<AND>
<NAME>.*</NAME>
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_NAMESPACE>http://schemas.android.com/apk/res-auto</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_NAMESPACE>http://schemas.android.com/tools</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>

View file

@ -0,0 +1,95 @@
package fr.free.nrw.commons.explore
import android.content.Context
import android.content.res.Configuration
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.View.GONE
import android.view.View.VISIBLE
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import androidx.paging.PagedList
import androidx.paging.PagedListAdapter
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.MergeAdapter
import fr.free.nrw.commons.R
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment
import fr.free.nrw.commons.utils.ViewUtil
import kotlinx.android.synthetic.main.fragment_search_paginated.*
abstract class BaseSearchFragment<T> : CommonsDaggerSupportFragment(),
SearchFragmentContract.View<T> {
abstract val pagedListAdapter: PagedListAdapter<T,*>
abstract val injectedPresenter: SearchFragmentContract.Presenter<T>
abstract val emptyTemplateTextId: Int
private val loadingAdapter by lazy { FooterAdapter { injectedPresenter.retryFailedRequest() } }
private val mergeAdapter by lazy { MergeAdapter(pagedListAdapter, loadingAdapter) }
private var searchResults: LiveData<PagedList<T>>? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
) = inflater.inflate(R.layout.fragment_search_paginated, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
paginatedSearchResultsList.apply {
layoutManager = GridLayoutManager(context, if (isPortrait) 1 else 2)
adapter = mergeAdapter
}
injectedPresenter.listFooterData.observe(
viewLifecycleOwner,
Observer(loadingAdapter::submitList)
)
}
override fun observeSearchResults(searchResults: LiveData<PagedList<T>>) {
this.searchResults?.removeObservers(viewLifecycleOwner)
this.searchResults = searchResults
searchResults.observe(viewLifecycleOwner, Observer {
pagedListAdapter.submitList(it)
contentNotFound.visibility = if (it.loadedCount == 0) VISIBLE else GONE
})
}
override fun onAttach(context: Context) {
super.onAttach(context)
injectedPresenter.onAttachView(this)
}
override fun onDetach() {
super.onDetach()
injectedPresenter.onDetachView()
}
override fun setEmptyViewText(query: String) {
contentNotFound.text = getString(emptyTemplateTextId, query)
}
override fun hideInitialLoadProgress() {
paginatedSearchInitialLoadProgress.visibility = View.GONE
}
override fun showInitialLoadInProgress() {
paginatedSearchInitialLoadProgress.visibility = View.VISIBLE
}
override fun showSnackbar() {
ViewUtil.showShortSnackbar(paginatedSearchResultsList, R.string.error_loading_depictions)
}
fun onQueryUpdated(query: String) {
injectedPresenter.onQueryUpdated(query)
}
}
private val Fragment.isPortrait get() = orientation == Configuration.ORIENTATION_PORTRAIT
private val Fragment.orientation get() = activity!!.resources.configuration.orientation

View file

@ -0,0 +1,69 @@
package fr.free.nrw.commons.explore
import androidx.lifecycle.MutableLiveData
import fr.free.nrw.commons.upload.depicts.proxy
import io.reactivex.Scheduler
import io.reactivex.disposables.CompositeDisposable
import timber.log.Timber
abstract class BaseSearchPresenter<T>(
val mainThreadScheduler: Scheduler,
val pageableDataSource: PageableDataSource<T>
) : SearchFragmentContract.Presenter<T> {
private val DUMMY: SearchFragmentContract.View<T> = proxy()
private var view: SearchFragmentContract.View<T> = DUMMY
private var currentQuery: String? = null
private val compositeDisposable = CompositeDisposable()
override val listFooterData = MutableLiveData<List<FooterItem>>().apply { value = emptyList() }
override fun onAttachView(view: SearchFragmentContract.View<T>) {
this.view = view
compositeDisposable.addAll(
pageableDataSource.searchResults.subscribe(view::observeSearchResults),
pageableDataSource.loadingStates
.observeOn(mainThreadScheduler)
.subscribe(::onLoadingState, Timber::e),
pageableDataSource.noItemsLoadedEvent.subscribe {
setEmptyViewText()
}
)
}
private fun onLoadingState(it: LoadingState) = when (it) {
LoadingState.Loading -> listFooterData.postValue(listOf(FooterItem.LoadingItem))
LoadingState.Complete -> {
listFooterData.postValue(emptyList())
view.hideInitialLoadProgress()
}
LoadingState.InitialLoad -> view.showInitialLoadInProgress()
LoadingState.Error -> {
setEmptyViewText()
view.showSnackbar()
view.hideInitialLoadProgress()
listFooterData.postValue(listOf(FooterItem.RefreshItem))
}
}
private fun setEmptyViewText() {
currentQuery?.let(view::setEmptyViewText)
}
override fun retryFailedRequest() {
pageableDataSource.retryFailedRequest()
}
override fun onDetachView() {
view = DUMMY
compositeDisposable.clear()
}
override fun onQueryUpdated(query: String) {
currentQuery = query
pageableDataSource.onQueryUpdated(query)
}
}

View file

@ -0,0 +1,10 @@
package fr.free.nrw.commons.explore
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.extensions.LayoutContainer
abstract class BaseViewHolder<T>(override val containerView: View) :
RecyclerView.ViewHolder(containerView), LayoutContainer {
abstract fun bind(item: T)
}

View file

@ -1,4 +1,4 @@
package fr.free.nrw.commons.explore.depictions
package fr.free.nrw.commons.explore
import android.view.LayoutInflater
import android.view.View

View file

@ -125,7 +125,7 @@ public class SearchActivity extends NavigationBaseActivity
}
if (FragmentUtils.isFragmentUIActive(searchCategoryFragment)) {
searchCategoryFragment.updateCategoryList(query.toString());
searchCategoryFragment.onQueryUpdated(query.toString());
}
} else {

View file

@ -0,0 +1,14 @@
package fr.free.nrw.commons.explore
import fr.free.nrw.commons.di.CommonsApplicationModule
import fr.free.nrw.commons.explore.categories.SearchCategoriesFragmentContract
import fr.free.nrw.commons.explore.categories.PageableCategoriesDataSource
import io.reactivex.Scheduler
import javax.inject.Inject
import javax.inject.Named
class SearchCategoriesFragmentPresenter @Inject constructor(
@Named(CommonsApplicationModule.MAIN_THREAD) mainThreadScheduler: Scheduler,
dataSourceFactory: PageableCategoriesDataSource
) : BaseSearchPresenter<String>(mainThreadScheduler, dataSourceFactory),
SearchCategoriesFragmentContract.Presenter

View file

@ -1,54 +1,17 @@
package fr.free.nrw.commons.explore.depictions
package fr.free.nrw.commons.explore
import androidx.paging.PositionalDataSource
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
import fr.free.nrw.commons.explore.depictions.LoadFunction
import io.reactivex.Completable
import io.reactivex.processors.PublishProcessor
import io.reactivex.schedulers.Schedulers
import timber.log.Timber
data class SearchDepictionsDataSource constructor(
private val depictsClient: DepictsClient,
private val loadingStates: PublishProcessor<LoadingState>,
private val query: String
) : PositionalDataSource<DepictedItem>() {
abstract class SearchDataSource<T>(
private val loadingStates: PublishProcessor<LoadingState>
) : PositionalDataSource<T>() {
private var lastExecutedRequest: (() -> Boolean)? = null
override fun loadInitial(
params: LoadInitialParams,
callback: LoadInitialCallback<DepictedItem>
) {
storeAndExecute {
loadingStates.offer(LoadingState.InitialLoad)
performWithTryCatch {
callback.onResult(
getItems(query, params.requestedLoadSize, params.requestedStartPosition),
params.requestedStartPosition
)
}
}
}
override fun loadRange(params: LoadRangeParams, callback: LoadRangeCallback<DepictedItem>) {
storeAndExecute {
loadingStates.offer(LoadingState.Loading)
performWithTryCatch {
callback.onResult(getItems(query, params.loadSize, params.startPosition))
}
}
}
fun retryFailedRequest() {
Completable.fromAction { lastExecutedRequest?.invoke() }
.subscribeOn(Schedulers.io())
.subscribe()
}
private fun getItems(query: String, limit: Int, offset: Int) =
depictsClient.searchForDepictions(query, limit, offset).blockingGet()
private fun storeAndExecute(function: () -> Boolean) {
function.also { lastExecutedRequest = it }.invoke()
}
@ -60,4 +23,45 @@ data class SearchDepictionsDataSource constructor(
Timber.e(e)
loadingStates.offer(LoadingState.Error)
}
override fun loadInitial(
params: LoadInitialParams,
callback: LoadInitialCallback<T>
) {
storeAndExecute {
loadingStates.offer(LoadingState.InitialLoad)
performWithTryCatch {
callback.onResult(
getItems(params.requestedLoadSize, params.requestedStartPosition),
params.requestedStartPosition
)
}
}
}
override fun loadRange(params: LoadRangeParams, callback: LoadRangeCallback<T>) {
storeAndExecute {
loadingStates.offer(LoadingState.Loading)
performWithTryCatch {
callback.onResult(getItems(params.loadSize, params.startPosition))
}
}
}
protected abstract fun getItems(loadSize: Int, startPosition: Int): List<T>
fun retryFailedRequest() {
Completable.fromAction { lastExecutedRequest?.invoke() }
.subscribeOn(Schedulers.io())
.subscribe()
}
}
fun <T> dataSource(
loadingStates: PublishProcessor<LoadingState>,
loadFunction: LoadFunction<T>
) = object : SearchDataSource<T>(loadingStates) {
override fun getItems(loadSize: Int, startPosition: Int): List<T> {
return loadFunction(loadSize, startPosition)
}
}

View file

@ -1,11 +1,12 @@
package fr.free.nrw.commons.explore.depictions
package fr.free.nrw.commons.explore
import androidx.lifecycle.LiveData
import androidx.paging.Config
import androidx.paging.DataSource
import androidx.paging.PagedList
import androidx.paging.toLiveData
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
import fr.free.nrw.commons.explore.depictions.LoadFunction
import fr.free.nrw.commons.explore.depictions.LoadingStates
import io.reactivex.Flowable
import io.reactivex.processors.PublishProcessor
import javax.inject.Inject
@ -13,25 +14,28 @@ import javax.inject.Inject
private const val PAGE_SIZE = 50
private const val INITIAL_LOAD_SIZE = 50
class SearchableDepictionsDataSourceFactory @Inject constructor(
val searchDepictionsDataSourceFactoryFactory: SearchDepictionsDataSourceFactoryFactory,
val liveDataConverter: LiveDataConverter
) {
abstract class PageableDataSource<T>(private val liveDataConverter: LiveDataConverter) {
lateinit var query: String
private val dataSourceFactoryFactory: () -> SearchDataSourceFactory<T> = {
dataSourceFactory(_loadingStates, loadFunction)
}
private val _loadingStates = PublishProcessor.create<LoadingState>()
val loadingStates: Flowable<LoadingState> = _loadingStates
private val _searchResults = PublishProcessor.create<LiveData<PagedList<DepictedItem>>>()
val searchResults: Flowable<LiveData<PagedList<DepictedItem>>> = _searchResults
private val _searchResults = PublishProcessor.create<LiveData<PagedList<T>>>()
val searchResults: Flowable<LiveData<PagedList<T>>> = _searchResults
private val _noItemsLoadedEvent = PublishProcessor.create<Unit>()
val noItemsLoadedEvent: Flowable<Unit> = _noItemsLoadedEvent
private var currentFactory: SearchDataSourceFactory<T>? = null
private var currentFactory: SearchDepictionsDataSourceFactory? = null
abstract val loadFunction: LoadFunction<T>
fun onQueryUpdated(query: String) {
this.query = query
_searchResults.offer(
liveDataConverter.convert(
searchDepictionsDataSourceFactoryFactory.create(query, _loadingStates)
.also { currentFactory = it }
) { _noItemsLoadedEvent.offer(Unit) }
liveDataConverter.convert(dataSourceFactoryFactory().also { currentFactory = it }) {
_noItemsLoadedEvent.offer(Unit)
}
)
}
@ -41,17 +45,17 @@ class SearchableDepictionsDataSourceFactory @Inject constructor(
}
class LiveDataConverter @Inject constructor() {
fun convert(
dataSourceFactory: SearchDepictionsDataSourceFactory,
fun <T> convert(
dataSourceFactory: SearchDataSourceFactory<T>,
zeroItemsLoadedFunction: () -> Unit
): LiveData<PagedList<DepictedItem>> {
): LiveData<PagedList<T>> {
return dataSourceFactory.toLiveData(
Config(
pageSize = PAGE_SIZE,
initialLoadSizeHint = INITIAL_LOAD_SIZE,
enablePlaceholders = false
),
boundaryCallback = object : PagedList.BoundaryCallback<DepictedItem>() {
boundaryCallback = object : PagedList.BoundaryCallback<T>() {
override fun onZeroItemsLoaded() {
zeroItemsLoadedFunction()
}
@ -61,25 +65,25 @@ class LiveDataConverter @Inject constructor() {
}
interface SearchDepictionsDataSourceFactoryFactory {
fun create(query: String, loadingStates: PublishProcessor<LoadingState>)
: SearchDepictionsDataSourceFactory
}
abstract class SearchDataSourceFactory<T>(val loadingStates: LoadingStates) :
DataSource.Factory<Int, T>() {
private var currentDataSource: SearchDataSource<T>? = null
abstract val loadFunction: LoadFunction<T>
class SearchDepictionsDataSourceFactory constructor(
private val depictsClient: DepictsClient,
private val query: String,
private val loadingStates: PublishProcessor<LoadingState>
) : DataSource.Factory<Int, DepictedItem>() {
private var currentDataSource: SearchDepictionsDataSource? = null
override fun create() = SearchDepictionsDataSource(depictsClient, loadingStates, query)
.also { currentDataSource = it }
override fun create() =
dataSource(loadingStates, loadFunction).also { currentDataSource = it }
fun retryFailedRequest() {
currentDataSource?.retryFailedRequest()
}
}
fun <T> dataSourceFactory(loadingStates: LoadingStates, loadFunction: LoadFunction<T>) =
object : SearchDataSourceFactory<T>(loadingStates) {
override val loadFunction: LoadFunction<T> = loadFunction
}
sealed class LoadingState {
object InitialLoad : LoadingState()
object Loading : LoadingState()

View file

@ -0,0 +1,21 @@
package fr.free.nrw.commons.explore
import androidx.lifecycle.LiveData
import androidx.paging.PagedList
import fr.free.nrw.commons.BasePresenter
interface SearchFragmentContract {
interface View<T> {
fun showSnackbar()
fun observeSearchResults(searchResults: LiveData<PagedList<T>>)
fun setEmptyViewText(query: String)
fun showInitialLoadInProgress()
fun hideInitialLoadProgress()
}
interface Presenter<T> : BasePresenter<View<T>> {
val listFooterData: LiveData<List<FooterItem>>
fun onQueryUpdated(query: String)
fun retryFailedRequest()
}
}

View file

@ -1,29 +0,0 @@
package fr.free.nrw.commons.explore;
import dagger.Binds;
import dagger.Module;
import dagger.Provides;
import fr.free.nrw.commons.explore.depictions.DepictsClient;
import fr.free.nrw.commons.explore.depictions.SearchDepictionsDataSourceFactory;
import fr.free.nrw.commons.explore.depictions.SearchDepictionsDataSourceFactoryFactory;
import fr.free.nrw.commons.explore.depictions.SearchDepictionsFragmentContract;
import fr.free.nrw.commons.explore.depictions.SearchDepictionsFragmentPresenter;
/**
* The Dagger Module for explore:depictions related presenters and (some other objects maybe in future)
*/
@Module
public abstract class SearchModule {
@Binds
public abstract SearchDepictionsFragmentContract.UserActionListener bindsSearchDepictionsFragmentPresenter(
SearchDepictionsFragmentPresenter
presenter
);
@Provides
static public SearchDepictionsDataSourceFactoryFactory providesSearchDepictionsFactoryFactory(
DepictsClient depictsClient){
return (query, loadingStates) -> new SearchDepictionsDataSourceFactory(depictsClient, query, loadingStates);
}
}

View file

@ -0,0 +1,24 @@
package fr.free.nrw.commons.explore
import dagger.Binds
import dagger.Module
import fr.free.nrw.commons.explore.categories.SearchCategoriesFragmentContract
import fr.free.nrw.commons.explore.depictions.SearchDepictionsFragmentContract
import fr.free.nrw.commons.explore.depictions.SearchDepictionsFragmentPresenter
/**
* The Dagger Module for explore:depictions related presenters and (some other objects maybe in future)
*/
@Module
abstract class SearchModule {
@Binds
abstract fun bindsSearchDepictionsFragmentPresenter(
presenter: SearchDepictionsFragmentPresenter
): SearchDepictionsFragmentContract.Presenter
@Binds
abstract fun bindsSearchCategoriesFragmentPresenter(
presenter: SearchCategoriesFragmentPresenter?
): SearchCategoriesFragmentContract.Presenter?
}

View file

@ -0,0 +1,16 @@
package fr.free.nrw.commons.explore.categories
import fr.free.nrw.commons.category.CategoryClient
import fr.free.nrw.commons.explore.LiveDataConverter
import fr.free.nrw.commons.explore.PageableDataSource
import javax.inject.Inject
class PageableCategoriesDataSource @Inject constructor(
liveDataConverter: LiveDataConverter,
val categoryClient: CategoryClient
) : PageableDataSource<String>(liveDataConverter) {
override val loadFunction = { loadSize: Int, startPosition: Int ->
categoryClient.searchCategories(query, loadSize, startPosition).blockingFirst()
}
}

View file

@ -0,0 +1,45 @@
package fr.free.nrw.commons.explore.categories
import android.view.View
import android.view.ViewGroup
import androidx.paging.PagedListAdapter
import androidx.recyclerview.widget.DiffUtil
import fr.free.nrw.commons.R
import fr.free.nrw.commons.category.CATEGORY_PREFIX
import fr.free.nrw.commons.explore.BaseViewHolder
import fr.free.nrw.commons.explore.inflate
import kotlinx.android.synthetic.main.item_recent_searches.*
class PagedSearchCategoriesAdapter(val onCategoryClicked: (String) -> Unit) :
PagedListAdapter<String, CategoryItemViewHolder>(
object : DiffUtil.ItemCallback<String>() {
override fun areItemsTheSame(oldItem: String, newItem: String) =
oldItem == newItem
override fun areContentsTheSame(oldItem: String, newItem: String) =
oldItem == newItem
}
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CategoryItemViewHolder {
return CategoryItemViewHolder(
parent.inflate(R.layout.item_recent_searches),
onCategoryClicked
)
}
override fun onBindViewHolder(holder: CategoryItemViewHolder, position: Int) {
holder.bind(getItem(position)!!)
}
}
class CategoryItemViewHolder(containerView: View, val onCategoryClicked: (String) -> Unit) :
BaseViewHolder<String>(containerView) {
override fun bind(item: String) {
containerView.setOnClickListener { onCategoryClicked(item) }
textView1.text = item.substringAfter(CATEGORY_PREFIX)
}
}

View file

@ -0,0 +1,8 @@
package fr.free.nrw.commons.explore.categories
import fr.free.nrw.commons.explore.SearchFragmentContract
interface SearchCategoriesFragmentContract {
interface View : SearchFragmentContract.View<String>
interface Presenter : SearchFragmentContract.Presenter<String>
}

View file

@ -1,214 +0,0 @@
package fr.free.nrw.commons.explore.categories;
import static android.view.View.GONE;
import static android.view.View.VISIBLE;
import android.content.res.Configuration;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ProgressBar;
import android.widget.TextView;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import butterknife.BindView;
import butterknife.ButterKnife;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.category.CategoryClient;
import fr.free.nrw.commons.category.CategoryDetailsActivity;
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment;
import fr.free.nrw.commons.explore.recentsearches.RecentSearch;
import fr.free.nrw.commons.explore.recentsearches.RecentSearchesDao;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.utils.NetworkUtils;
import fr.free.nrw.commons.utils.ViewUtil;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.schedulers.Schedulers;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Named;
import kotlin.Unit;
import timber.log.Timber;
/**
* Displays the category search screen.
*/
public class SearchCategoryFragment extends CommonsDaggerSupportFragment {
@BindView(R.id.imagesListBox)
RecyclerView categoriesRecyclerView;
@BindView(R.id.imageSearchInProgress)
ProgressBar progressBar;
@BindView(R.id.imagesNotFound)
TextView categoriesNotFoundView;
String query;
@BindView(R.id.bottomProgressBar)
ProgressBar bottomProgressBar;
boolean isLoadingCategories;
@Inject RecentSearchesDao recentSearchesDao;
@Inject CategoryClient categoryClient;
@Inject
@Named("default_preferences")
JsonKvStore basicKvStore;
private SearchCategoriesAdapter categoriesAdapter;
private List<String> queryList = new ArrayList<>();
/**
* This method saves Search Query in the Recent Searches Database.
* @param query
*/
private void saveQuery(String query) {
RecentSearch recentSearch = recentSearchesDao.find(query);
// Newly searched query...
if (recentSearch == null) {
recentSearch = new RecentSearch(null, query, new Date());
}
else {
recentSearch.setLastSearched(new Date());
}
recentSearchesDao.save(recentSearch);
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,Bundle savedInstanceState) {
View rootView = inflater.inflate(R.layout.fragment_browse_image, container, false);
ButterKnife.bind(this, rootView);
if (getActivity().getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT){
categoriesRecyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
}
else{
categoriesRecyclerView.setLayoutManager(new GridLayoutManager(getContext(), 2));
}
categoriesAdapter = new SearchCategoriesAdapter(item -> {
CategoryDetailsActivity.startYourself(getContext(), item);
saveQuery(query);
return Unit.INSTANCE;
});
categoriesRecyclerView.setAdapter(categoriesAdapter);
categoriesRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
// check if end of recycler view is reached, if yes then add more results to existing results
if (!recyclerView.canScrollVertically(1)) {
addCategoriesToList(query);
}
}
});
return rootView;
}
/**
* Checks for internet connection and then initializes the recycler view with 25 categories of the searched query
* Clearing categoryAdapter every time new keyword is searched so that user can see only new results
*/
public void updateCategoryList(String query) {
this.query = query;
categoriesNotFoundView.setVisibility(GONE);
if (!NetworkUtils.isInternetConnectionEstablished(getContext())) {
handleNoInternet();
return;
}
bottomProgressBar.setVisibility(View.VISIBLE);
progressBar.setVisibility(GONE);
queryList.clear();
categoriesAdapter.clear();
compositeDisposable.add(categoryClient.searchCategories(query,25)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doOnSubscribe(disposable -> saveQuery(query))
.subscribe(this::handleSuccess, this::handleError));
}
/**
* Adds 25 more results to existing search results
*/
public void addCategoriesToList(String query) {
if(isLoadingCategories) {
return;
}
isLoadingCategories=true;
this.query = query;
bottomProgressBar.setVisibility(View.VISIBLE);
progressBar.setVisibility(GONE);
compositeDisposable.add(categoryClient.searchCategories(query,25, queryList.size())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(this::handlePaginationSuccess, this::handleError));
}
/**
* Handles the success scenario
* it initializes the recycler view by adding items to the adapter
*/
private void handlePaginationSuccess(List<String> mediaList) {
queryList.addAll(mediaList);
progressBar.setVisibility(View.GONE);
bottomProgressBar.setVisibility(GONE);
categoriesAdapter.addAll(mediaList);
isLoadingCategories=false;
}
/**
* Handles the success scenario
* it initializes the recycler view by adding items to the adapter
*/
private void handleSuccess(List<String> mediaList) {
queryList = mediaList;
if (mediaList == null || mediaList.isEmpty()) {
initErrorView();
}
else {
bottomProgressBar.setVisibility(View.GONE);
progressBar.setVisibility(GONE);
categoriesAdapter.addAll(mediaList);
}
}
/**
* Logs and handles API error scenario
*/
private void handleError(Throwable throwable) {
Timber.e(throwable, "Error occurred while loading queried categories");
try {
initErrorView();
ViewUtil.showShortSnackbar(categoriesRecyclerView, R.string.error_loading_categories);
}catch (Exception e){
e.printStackTrace();
}
}
/**
* Handles the UI updates for a error scenario
*/
private void initErrorView() {
progressBar.setVisibility(GONE);
categoriesNotFoundView.setVisibility(VISIBLE);
categoriesNotFoundView.setText(getString(R.string.categories_not_found,query));
}
/**
* Handles the UI updates for no internet scenario
*/
private void handleNoInternet() {
progressBar.setVisibility(GONE);
ViewUtil.showShortSnackbar(categoriesRecyclerView, R.string.no_internet);
}
}

View file

@ -0,0 +1,24 @@
package fr.free.nrw.commons.explore.categories
import fr.free.nrw.commons.R
import fr.free.nrw.commons.category.CategoryDetailsActivity
import fr.free.nrw.commons.explore.BaseSearchFragment
import fr.free.nrw.commons.explore.SearchFragmentContract
import javax.inject.Inject
/**
* Displays the category search screen.
*/
class SearchCategoryFragment : BaseSearchFragment<String>() {
@Inject
lateinit var presenter: SearchCategoriesFragmentContract.Presenter
override val emptyTemplateTextId: Int = R.string.categories_not_found
override val injectedPresenter: SearchFragmentContract.Presenter<String>
get() = presenter
override val pagedListAdapter by lazy {
PagedSearchCategoriesAdapter { CategoryDetailsActivity.startYourself(context, it) }
}
}

View file

@ -4,10 +4,10 @@ import android.view.View
import android.view.ViewGroup
import androidx.paging.PagedListAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import fr.free.nrw.commons.R
import fr.free.nrw.commons.explore.BaseViewHolder
import fr.free.nrw.commons.explore.inflate
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.item_depictions.*
@ -19,22 +19,21 @@ class DepictionAdapter(val onDepictionClicked: (DepictedItem) -> Unit) :
override fun areContentsTheSame(oldItem: DepictedItem, newItem: DepictedItem) =
oldItem == newItem
}
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DepictedItemViewHolder {
return DepictedItemViewHolder(parent.inflate(R.layout.item_depictions))
return DepictedItemViewHolder(parent.inflate(R.layout.item_depictions), onDepictionClicked)
}
override fun onBindViewHolder(holder: DepictedItemViewHolder, position: Int) {
holder.bind(getItem(position)!!, onDepictionClicked)
holder.bind(getItem(position)!!)
}
}
class DepictedItemViewHolder(override val containerView: View) :
RecyclerView.ViewHolder(containerView), LayoutContainer {
fun bind(item: DepictedItem, onDepictionClicked: (DepictedItem) -> Unit) {
class DepictedItemViewHolder(containerView: View, val onDepictionClicked: (DepictedItem) -> Unit) :
BaseViewHolder<DepictedItem>(containerView) {
override fun bind(item: DepictedItem) {
containerView.setOnClickListener { onDepictionClicked(item) }
depicts_label.text = item.name
description.text = item.description

View file

@ -0,0 +1,22 @@
package fr.free.nrw.commons.explore.depictions
import fr.free.nrw.commons.explore.LiveDataConverter
import fr.free.nrw.commons.explore.LoadingState
import fr.free.nrw.commons.explore.PageableDataSource
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
import io.reactivex.processors.PublishProcessor
import javax.inject.Inject
typealias LoadFunction<T> = (Int, Int) -> List<T>
typealias LoadingStates = PublishProcessor<LoadingState>
class PageableDepictionsDataSource @Inject constructor(
liveDataConverter: LiveDataConverter,
val depictsClient: DepictsClient
) : PageableDataSource<DepictedItem>(liveDataConverter) {
override val loadFunction = { loadSize: Int, startPosition: Int ->
depictsClient.searchForDepictions(query, loadSize, startPosition).blockingGet()
}
}

View file

@ -1,99 +1,26 @@
package fr.free.nrw.commons.explore.depictions
import android.content.Context
import android.content.res.Configuration
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.View.GONE
import android.view.View.VISIBLE
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import androidx.paging.PagedList
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.MergeAdapter
import fr.free.nrw.commons.R
import fr.free.nrw.commons.depictions.WikidataItemDetailsActivity
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment
import fr.free.nrw.commons.explore.BaseSearchFragment
import fr.free.nrw.commons.explore.SearchFragmentContract
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
import fr.free.nrw.commons.utils.ViewUtil
import kotlinx.android.synthetic.main.fragment_search_depictions.*
import javax.inject.Inject
/**
* Display depictions in search fragment
*/
class SearchDepictionsFragment : CommonsDaggerSupportFragment(),
class SearchDepictionsFragment : BaseSearchFragment<DepictedItem>(),
SearchDepictionsFragmentContract.View {
@Inject
lateinit var presenter: SearchDepictionsFragmentContract.UserActionListener
lateinit var presenter: SearchDepictionsFragmentContract.Presenter
private val depictionsAdapter by lazy {
override val emptyTemplateTextId: Int = R.string.depictions_not_found
override val injectedPresenter: SearchFragmentContract.Presenter<DepictedItem>
get() = presenter
override val pagedListAdapter by lazy {
DepictionAdapter { WikidataItemDetailsActivity.startYourself(context, it) }
}
private val loadingAdapter by lazy { FooterAdapter { presenter.retryFailedRequest() } }
private val mergeAdapter by lazy { MergeAdapter(depictionsAdapter, loadingAdapter) }
var searchResults: LiveData<PagedList<DepictedItem>>? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
) = inflater.inflate(R.layout.fragment_search_depictions, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
depictionsSearchResultsList.apply {
layoutManager = GridLayoutManager(context, if (isPortrait) 1 else 2)
adapter = mergeAdapter
}
presenter.listFooterData.observe(viewLifecycleOwner, Observer(loadingAdapter::submitList))
}
override fun onAttach(context: Context) {
super.onAttach(context)
presenter.onAttachView(this)
}
override fun onDetach() {
super.onDetach()
presenter.onDetachView()
}
override fun observeSearchResults(searchResults: LiveData<PagedList<DepictedItem>>) {
this.searchResults?.removeObservers(viewLifecycleOwner)
this.searchResults = searchResults
searchResults.observe(viewLifecycleOwner, Observer {
depictionsAdapter.submitList(it)
depictionNotFound.visibility = if (it.loadedCount == 0) VISIBLE else GONE
})
}
override fun setEmptyViewText(query: String) {
depictionNotFound.text = getString(R.string.depictions_not_found, query)
}
override fun hideInitialLoadProgress() {
depictionSearchInitialLoadProgress.visibility = GONE
}
override fun showInitialLoadInProgress() {
depictionSearchInitialLoadProgress.visibility = VISIBLE
}
override fun showSnackbar() {
ViewUtil.showShortSnackbar(depictionsSearchResultsList, R.string.error_loading_depictions)
}
fun onQueryUpdated(query: String) {
presenter.onQueryUpdated(query)
}
}
private val Fragment.isPortrait get() = orientation == Configuration.ORIENTATION_PORTRAIT
private val Fragment.orientation get() = activity!!.resources.configuration.orientation

View file

@ -1,25 +1,12 @@
package fr.free.nrw.commons.explore.depictions
import androidx.lifecycle.LiveData
import androidx.paging.PagedList
import fr.free.nrw.commons.BasePresenter
import fr.free.nrw.commons.explore.SearchFragmentContract
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
/**
* The contract with with SearchDepictionsFragment and its presenter would talk to each other
*/
interface SearchDepictionsFragmentContract {
interface View {
fun showSnackbar()
fun observeSearchResults(searchResults: LiveData<PagedList<DepictedItem>>)
fun setEmptyViewText(query: String)
fun showInitialLoadInProgress()
fun hideInitialLoadProgress()
}
interface UserActionListener : BasePresenter<View?> {
val listFooterData: LiveData<List<FooterItem>>
fun onQueryUpdated(query: String)
fun retryFailedRequest()
}
interface View : SearchFragmentContract.View<DepictedItem>
interface Presenter : SearchFragmentContract.Presenter<DepictedItem>
}

View file

@ -1,11 +1,9 @@
package fr.free.nrw.commons.explore.depictions
import androidx.lifecycle.MutableLiveData
import fr.free.nrw.commons.di.CommonsApplicationModule
import fr.free.nrw.commons.upload.depicts.proxy
import fr.free.nrw.commons.explore.BaseSearchPresenter
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
import io.reactivex.Scheduler
import io.reactivex.disposables.CompositeDisposable
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Named
@ -13,61 +11,7 @@ import javax.inject.Named
* The presenter class for SearchDepictionsFragment
*/
class SearchDepictionsFragmentPresenter @Inject constructor(
@param:Named(CommonsApplicationModule.MAIN_THREAD) private val mainThreadScheduler: Scheduler,
private val searchableDataSourceFactory: SearchableDepictionsDataSourceFactory
) : SearchDepictionsFragmentContract.UserActionListener {
private val compositeDisposable = CompositeDisposable()
private var view = DUMMY
private var currentQuery: String? = null
override val listFooterData = MutableLiveData<List<FooterItem>>().apply { value = emptyList() }
override fun onAttachView(view: SearchDepictionsFragmentContract.View) {
this.view = view
compositeDisposable.addAll(
searchableDataSourceFactory.searchResults.subscribe(view::observeSearchResults),
searchableDataSourceFactory.loadingStates
.observeOn(mainThreadScheduler)
.subscribe(::onLoadingState, Timber::e),
searchableDataSourceFactory.noItemsLoadedEvent.subscribe {
setEmptyViewText()
}
)
}
private fun onLoadingState(it: LoadingState) = when (it) {
LoadingState.Loading -> listFooterData.postValue(listOf(FooterItem.LoadingItem))
LoadingState.Complete -> {
listFooterData.postValue(emptyList())
view.hideInitialLoadProgress()
}
LoadingState.InitialLoad -> view.showInitialLoadInProgress()
LoadingState.Error -> {
setEmptyViewText()
view.showSnackbar()
view.hideInitialLoadProgress()
listFooterData.postValue(listOf(FooterItem.RefreshItem))
}
}
private fun setEmptyViewText() {
currentQuery?.let(view::setEmptyViewText)
}
override fun retryFailedRequest() {
searchableDataSourceFactory.retryFailedRequest()
}
override fun onDetachView() {
view = DUMMY
compositeDisposable.clear()
}
override fun onQueryUpdated(query: String) {
currentQuery = query
searchableDataSourceFactory.onQueryUpdated(query)
}
companion object {
private val DUMMY: SearchDepictionsFragmentContract.View = proxy()
}
}
@Named(CommonsApplicationModule.MAIN_THREAD) mainThreadScheduler: Scheduler,
dataSourceFactory: PageableDepictionsDataSource
) : BaseSearchPresenter<DepictedItem>(mainThreadScheduler, dataSourceFactory),
SearchDepictionsFragmentContract.Presenter

View file

@ -2,11 +2,11 @@
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingTop="@dimen/tiny_gap"
android:orientation="vertical">
android:orientation="vertical"
android:paddingTop="@dimen/tiny_gap">
<TextView
android:id="@+id/depictionNotFound"
android:id="@+id/contentNotFound"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
@ -14,16 +14,16 @@
android:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/depictionsSearchResultsList"
android:id="@+id/paginatedSearchResultsList"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbarSize="@dimen/standard_gap"
android:scrollbars="vertical" />
<ProgressBar
android:id="@+id/depictionSearchInitialLoadProgress"
android:id="@+id/paginatedSearchInitialLoadProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"/>
android:layout_centerInParent="true" />
</RelativeLayout>

View file

@ -1,11 +1,10 @@
package fr.free.nrw.commons.explore.depictions
package fr.free.nrw.commons.explore
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.lifecycle.LiveData
import androidx.paging.PagedList
import com.jraska.livedata.test
import com.nhaarman.mockitokotlin2.*
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
import io.reactivex.processors.PublishProcessor
import io.reactivex.schedulers.TestScheduler
import org.junit.Before
@ -14,25 +13,25 @@ import org.junit.Test
import org.mockito.Mock
import org.mockito.MockitoAnnotations
class SearchDepictionsFragmentPresenterTest {
class BaseSearchPresenterTest {
@Rule
@JvmField
var instantTaskExecutorRule = InstantTaskExecutorRule()
@Mock
internal lateinit var view: SearchDepictionsFragmentContract.View
internal lateinit var view: SearchFragmentContract.View<String>
private lateinit var searchDepictionsFragmentPresenter: SearchDepictionsFragmentPresenter
private lateinit var baseSearchPresenter: BaseSearchPresenter<String>
private lateinit var testScheduler: TestScheduler
@Mock
private lateinit var searchableDepictionsDataSourceFactory: SearchableDepictionsDataSourceFactory
private lateinit var pageableDataSource: PageableDataSource<String>
private var loadingStates: PublishProcessor<LoadingState> = PublishProcessor.create()
private var searchResults: PublishProcessor<LiveData<PagedList<DepictedItem>>> =
private var searchResults: PublishProcessor<LiveData<PagedList<String>>> =
PublishProcessor.create()
private var noItemLoadedEvent: PublishProcessor<Unit> = PublishProcessor.create()
@ -41,21 +40,19 @@ class SearchDepictionsFragmentPresenterTest {
@Throws(Exception::class)
fun setUp() {
MockitoAnnotations.initMocks(this)
whenever(searchableDepictionsDataSourceFactory.searchResults).thenReturn(searchResults)
whenever(searchableDepictionsDataSourceFactory.loadingStates).thenReturn(loadingStates)
whenever(searchableDepictionsDataSourceFactory.noItemsLoadedEvent)
whenever(pageableDataSource.searchResults).thenReturn(searchResults)
whenever(pageableDataSource.loadingStates).thenReturn(loadingStates)
whenever(pageableDataSource.noItemsLoadedEvent)
.thenReturn(noItemLoadedEvent)
testScheduler = TestScheduler()
searchDepictionsFragmentPresenter = SearchDepictionsFragmentPresenter(
testScheduler,
searchableDepictionsDataSourceFactory
)
searchDepictionsFragmentPresenter.onAttachView(view)
baseSearchPresenter =
object : BaseSearchPresenter<String>(testScheduler, pageableDataSource) {}
baseSearchPresenter.onAttachView(view)
}
@Test
fun `searchResults emission updates the view`() {
val pagedListLiveData = mock<LiveData<PagedList<DepictedItem>>>()
val pagedListLiveData = mock<LiveData<PagedList<String>>>()
searchResults.offer(pagedListLiveData)
verify(view).observeSearchResults(pagedListLiveData)
}
@ -63,14 +60,13 @@ class SearchDepictionsFragmentPresenterTest {
@Test
fun `Loading offers a loading list item`() {
onLoadingState(LoadingState.Loading)
searchDepictionsFragmentPresenter.listFooterData.test()
.assertValue(listOf(FooterItem.LoadingItem))
baseSearchPresenter.listFooterData.test().assertValue(listOf(FooterItem.LoadingItem))
}
@Test
fun `Complete offers an empty list item and hides initial loader`() {
onLoadingState(LoadingState.Complete)
searchDepictionsFragmentPresenter.listFooterData.test()
baseSearchPresenter.listFooterData.test()
.assertValue(emptyList())
verify(view).hideInitialLoadProgress()
}
@ -83,13 +79,12 @@ class SearchDepictionsFragmentPresenterTest {
@Test
fun `Error offers a refresh list item, hides initial loader and shows error with a set text`() {
searchDepictionsFragmentPresenter.onQueryUpdated("test")
baseSearchPresenter.onQueryUpdated("test")
onLoadingState(LoadingState.Error)
verify(view).setEmptyViewText("test")
verify(view).showSnackbar()
verify(view).hideInitialLoadProgress()
searchDepictionsFragmentPresenter.listFooterData.test()
.assertValue(listOf(FooterItem.RefreshItem))
baseSearchPresenter.listFooterData.test().assertValue(listOf(FooterItem.RefreshItem))
}
@Test
@ -98,35 +93,33 @@ class SearchDepictionsFragmentPresenterTest {
verify(view, never()).setEmptyViewText(any())
verify(view).showSnackbar()
verify(view).hideInitialLoadProgress()
searchDepictionsFragmentPresenter.listFooterData.test()
.assertValue(listOf(FooterItem.RefreshItem))
baseSearchPresenter.listFooterData.test().assertValue(listOf(FooterItem.RefreshItem))
}
@Test
fun `no Items event sets empty view text`() {
searchDepictionsFragmentPresenter.onQueryUpdated("test")
baseSearchPresenter.onQueryUpdated("test")
noItemLoadedEvent.offer(Unit)
verify(view).setEmptyViewText("test")
}
@Test
fun `retryFailedRequest calls retry`() {
searchDepictionsFragmentPresenter.retryFailedRequest()
verify(searchableDepictionsDataSourceFactory).retryFailedRequest()
baseSearchPresenter.retryFailedRequest()
verify(pageableDataSource).retryFailedRequest()
}
@Test
fun `onDetachView stops subscriptions`() {
searchDepictionsFragmentPresenter.onDetachView()
baseSearchPresenter.onDetachView()
onLoadingState(LoadingState.Loading)
searchDepictionsFragmentPresenter.listFooterData.test()
.assertValue(emptyList())
baseSearchPresenter.listFooterData.test().assertValue(emptyList())
}
@Test
fun `onQueryUpdated updates dataSourceFactory`() {
searchDepictionsFragmentPresenter.onQueryUpdated("test")
verify(searchableDepictionsDataSourceFactory).onQueryUpdated("test")
baseSearchPresenter.onQueryUpdated("test")
verify(pageableDataSource).onQueryUpdated("test")
}
private fun onLoadingState(loadingState: LoadingState) {

View file

@ -0,0 +1,73 @@
package fr.free.nrw.commons.explore
import androidx.lifecycle.LiveData
import androidx.paging.PagedList
import com.nhaarman.mockitokotlin2.*
import fr.free.nrw.commons.explore.depictions.LoadFunction
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import org.mockito.Mock
import org.mockito.MockitoAnnotations
class PageableDataSourceTest {
@Mock
private lateinit var liveDataConverter: LiveDataConverter
private lateinit var pageableDataSource: PageableDataSource<String>
@Before
fun setUp() {
MockitoAnnotations.initMocks(this)
pageableDataSource = object: PageableDataSource<String>(liveDataConverter){
override val loadFunction: LoadFunction<String>
get() = mock()
}
}
@Test
fun `onQueryUpdated emits new liveData`() {
val (_, liveData) = expectNewLiveData()
pageableDataSource.searchResults.test()
.also { pageableDataSource.onQueryUpdated("test") }
.assertValue(liveData)
}
@Test
fun `onQueryUpdated invokes livedatconverter with no items emitter`() {
val (zeroItemsFuncCaptor, _) = expectNewLiveData()
pageableDataSource.onQueryUpdated("test")
pageableDataSource.noItemsLoadedEvent.test()
.also { zeroItemsFuncCaptor.firstValue.invoke() }
.assertValue(Unit)
}
/*
* Just for coverage, no way to really assert this
* */
@Test
fun `retryFailedRequest does nothing without a factory`() {
pageableDataSource.retryFailedRequest()
}
@Test
@Ignore("Rewrite with Mockk constructor mocks")
fun `retryFailedRequest retries with a factory`() {
val (_, _, dataSourceFactoryCaptor) = expectNewLiveData()
pageableDataSource.onQueryUpdated("test")
val dataSourceFactory = spy(dataSourceFactoryCaptor.firstValue)
pageableDataSource.retryFailedRequest()
verify(dataSourceFactory).retryFailedRequest()
}
private fun expectNewLiveData(): Triple<KArgumentCaptor<() -> Unit>, LiveData<PagedList<String>>, KArgumentCaptor<SearchDataSourceFactory<String>>> {
val captor = argumentCaptor<() -> Unit>()
val dataSourceFactoryCaptor = argumentCaptor<SearchDataSourceFactory<String>>()
val liveData: LiveData<PagedList<String>> = mock()
whenever(liveDataConverter.convert(dataSourceFactoryCaptor.capture(), captor.capture()))
.thenReturn(liveData)
return Triple(captor, liveData, dataSourceFactoryCaptor)
}
}

View file

@ -1,11 +1,12 @@
package fr.free.nrw.commons.explore.depictions
package fr.free.nrw.commons.explore
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.spy
import com.nhaarman.mockitokotlin2.verify
import fr.free.nrw.commons.explore.depictions.DepictsClient
import io.reactivex.processors.PublishProcessor
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.`is`
import org.hamcrest.Matchers.instanceOf
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
@ -13,26 +14,30 @@ import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.MockitoAnnotations
class SearchDepictionsDataSourceFactoryTest {
class SearchDataSourceFactoryTest {
@Mock
private lateinit var depictsClient: DepictsClient
@Mock
private lateinit var loadingStates: PublishProcessor<LoadingState>
private lateinit var factory: SearchDepictionsDataSourceFactory
private lateinit var factory: SearchDataSourceFactory<String>
private var function: (Int, Int) -> List<String> = mock()
@Before
fun setUp() {
MockitoAnnotations.initMocks(this)
factory = SearchDepictionsDataSourceFactory(depictsClient, "test", loadingStates)
factory = object : SearchDataSourceFactory<String>(loadingStates) {
override val loadFunction get() = function
}
}
@Test
fun `create returns a dataSource`() {
assertThat(
factory.create(),
`is`(SearchDepictionsDataSource(depictsClient, loadingStates, "test"))
instanceOf(SearchDataSource::class.java)
)
}
@ -40,7 +45,7 @@ class SearchDepictionsDataSourceFactoryTest {
@Ignore("Rewrite with Mockk constructor mocks")
fun `retryFailedRequest invokes method if not null`() {
val spyFactory = spy(factory)
val dataSource = mock<SearchDepictionsDataSource>()
val dataSource = mock<SearchDataSource<String>>()
Mockito.doReturn(dataSource).`when`(spyFactory).create()
factory.retryFailedRequest()
verify(dataSource).retryFailedRequest()

View file

@ -1,10 +1,8 @@
package fr.free.nrw.commons.explore.depictions
package fr.free.nrw.commons.explore
import androidx.paging.PositionalDataSource
import com.nhaarman.mockitokotlin2.*
import fr.free.nrw.commons.explore.depictions.LoadingState.*
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
import io.reactivex.Single
import fr.free.nrw.commons.explore.depictions.LoadingStates
import io.reactivex.plugins.RxJavaPlugins
import io.reactivex.processors.PublishProcessor
import io.reactivex.schedulers.Schedulers
@ -14,13 +12,13 @@ import org.junit.Test
import org.mockito.Mock
import org.mockito.MockitoAnnotations
class SearchDepictionsDataSourceTest {
@Mock
private lateinit var depictsClient: DepictsClient
class SearchDataSourceTest {
private lateinit var loadingStates: PublishProcessor<LoadingState>
private lateinit var searchDepictionsDataSource: SearchDepictionsDataSource
private lateinit var searchDepictionsDataSource: TestSearchDataSource
@Mock
private lateinit var mockGetItems: MockGetItems
@Before
fun setUp() {
@ -28,7 +26,10 @@ class SearchDepictionsDataSourceTest {
MockitoAnnotations.initMocks(this)
loadingStates = PublishProcessor.create()
searchDepictionsDataSource =
SearchDepictionsDataSource(depictsClient, loadingStates, "test")
TestSearchDataSource(
loadingStates,
mockGetItems
)
}
@After
@ -39,68 +40,81 @@ class SearchDepictionsDataSourceTest {
@Test
fun `loadInitial returns results and emits InitialLoad & Complete`() {
val params = PositionalDataSource.LoadInitialParams(0, 1, 2, false)
val callback = mock<PositionalDataSource.LoadInitialCallback<DepictedItem>>()
whenever(depictsClient.searchForDepictions("test", 1, 0))
.thenReturn(Single.just(emptyList()))
val callback = mock<PositionalDataSource.LoadInitialCallback<String>>()
whenever(mockGetItems.getItems(1, 0)).thenReturn(emptyList())
val testSubscriber = loadingStates.test()
searchDepictionsDataSource.loadInitial(params, callback)
verify(callback).onResult(emptyList(), 0)
testSubscriber.assertValues(InitialLoad, Complete)
testSubscriber.assertValues(LoadingState.InitialLoad, LoadingState.Complete)
}
@Test
fun `loadInitial onError does not return results and emits InitialLoad & Error`() {
val params = PositionalDataSource.LoadInitialParams(0, 1, 2, false)
val callback = mock<PositionalDataSource.LoadInitialCallback<DepictedItem>>()
whenever(depictsClient.searchForDepictions("test", 1, 0))
.thenThrow(RuntimeException())
val callback = mock<PositionalDataSource.LoadInitialCallback<String>>()
whenever(mockGetItems.getItems(1, 0)).thenThrow(RuntimeException())
val testSubscriber = loadingStates.test()
searchDepictionsDataSource.loadInitial(params, callback)
verify(callback, never()).onResult(any(), any())
testSubscriber.assertValues(InitialLoad, Error)
testSubscriber.assertValues(LoadingState.InitialLoad, LoadingState.Error)
}
@Test
fun `loadRange returns results and emits Loading & Complete`() {
val callback: PositionalDataSource.LoadRangeCallback<DepictedItem> = mock()
val callback: PositionalDataSource.LoadRangeCallback<String> = mock()
val params = PositionalDataSource.LoadRangeParams(0, 1)
whenever(depictsClient.searchForDepictions("test", params.loadSize, params.startPosition))
.thenReturn(Single.just(emptyList()))
whenever(mockGetItems.getItems(params.loadSize, params.startPosition))
.thenReturn(emptyList())
val testSubscriber = loadingStates.test()
searchDepictionsDataSource.loadRange(params, callback)
verify(callback).onResult(emptyList())
testSubscriber.assertValues(Loading, Complete)
testSubscriber.assertValues(LoadingState.Loading, LoadingState.Complete)
}
@Test
fun `loadRange onError does not return results and emits Loading & Error`() {
val callback: PositionalDataSource.LoadRangeCallback<DepictedItem> = mock()
val callback: PositionalDataSource.LoadRangeCallback<String> = mock()
val params = PositionalDataSource.LoadRangeParams(0, 1)
whenever(depictsClient.searchForDepictions("test", params.loadSize, params.startPosition))
whenever(mockGetItems.getItems(params.loadSize, params.startPosition))
.thenThrow(RuntimeException())
val testSubscriber = loadingStates.test()
searchDepictionsDataSource.loadRange(params, callback)
verify(callback, never()).onResult(any())
testSubscriber.assertValues(Loading, Error)
testSubscriber.assertValues(LoadingState.Loading, LoadingState.Error)
}
@Test
fun `retryFailedRequest does nothing when null`() {
searchDepictionsDataSource.retryFailedRequest()
verifyNoMoreInteractions(depictsClient)
verifyNoMoreInteractions(mockGetItems)
}
@Test
fun `retryFailedRequest retries last request`() {
val callback: PositionalDataSource.LoadRangeCallback<DepictedItem> = mock()
val callback: PositionalDataSource.LoadRangeCallback<String> = mock()
val params = PositionalDataSource.LoadRangeParams(0, 1)
whenever(depictsClient.searchForDepictions("test", params.loadSize, params.startPosition))
.thenThrow(RuntimeException()).thenReturn(Single.just(emptyList()))
whenever(mockGetItems.getItems(params.loadSize, params.startPosition))
.thenThrow(RuntimeException()).thenReturn(emptyList())
val testSubscriber = loadingStates.test()
searchDepictionsDataSource.loadRange(params, callback)
verify(callback, never()).onResult(any())
searchDepictionsDataSource.retryFailedRequest()
verify(callback).onResult(emptyList())
testSubscriber.assertValues(Loading, Error, Loading, Complete)
testSubscriber.assertValues(
LoadingState.Loading,
LoadingState.Error,
LoadingState.Loading,
LoadingState.Complete
)
}
}
class TestSearchDataSource(loadingStates: LoadingStates, val mockGetItems: MockGetItems) :
SearchDataSource<String>(loadingStates) {
override fun getItems(loadSize: Int, startPosition: Int): List<String> =
mockGetItems.getItems(loadSize, startPosition)
}
interface MockGetItems {
fun getItems(loadSize: Int, startPosition: Int): List<String>
}

View file

@ -0,0 +1,22 @@
package fr.free.nrw.commons.explore.categroies
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.whenever
import fr.free.nrw.commons.category.CategoryClient
import fr.free.nrw.commons.explore.categories.PageableCategoriesDataSource
import io.reactivex.Observable
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers
import org.junit.Test
class PageableCategoriesDataSourceTest {
@Test
fun `loadFunction loads categories`() {
val categoryClient: CategoryClient = mock()
whenever(categoryClient.searchCategories("test", 0, 1))
.thenReturn(Observable.just(emptyList()))
val pageableCategoriesDataSource = PageableCategoriesDataSource(mock(), categoryClient)
pageableCategoriesDataSource.onQueryUpdated("test")
assertThat(pageableCategoriesDataSource.loadFunction(0, 1), Matchers.`is`(emptyList()))
}
}

View file

@ -0,0 +1,21 @@
package fr.free.nrw.commons.explore.depictions
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.whenever
import io.reactivex.Single
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers
import org.junit.Test
class PageableDepictionsDataSourceTest {
@Test
fun `loadFunction loads depictions`() {
val depictsClient: DepictsClient = mock()
whenever(depictsClient.searchForDepictions("test", 0, 1)).thenReturn(Single.just(emptyList()))
val pageableDepictionsDataSource = PageableDepictionsDataSource(mock(), depictsClient)
pageableDepictionsDataSource.onQueryUpdated("test")
assertThat(pageableDepictionsDataSource.loadFunction.invoke(0, 1), Matchers.`is`(emptyList()))
}
}

View file

@ -1,80 +0,0 @@
package fr.free.nrw.commons.explore.depictions
import androidx.lifecycle.LiveData
import androidx.paging.PagedList
import com.nhaarman.mockitokotlin2.*
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
import io.reactivex.processors.PublishProcessor
import org.junit.Before
import org.junit.Test
import org.mockito.Mock
import org.mockito.MockitoAnnotations
class SearchableDepictionsDataSourceFactoryTest {
@Mock
private lateinit var searchDepictionsDataSourceFactoryFactory: SearchDepictionsDataSourceFactoryFactory
@Mock
private lateinit var liveDataConverter: LiveDataConverter
private lateinit var factory: SearchableDepictionsDataSourceFactory
@Before
fun setUp() {
MockitoAnnotations.initMocks(this)
factory = SearchableDepictionsDataSourceFactory(
searchDepictionsDataSourceFactoryFactory,
liveDataConverter
)
}
@Test
fun `onQueryUpdated emits new liveData`() {
val (_, liveData) = expectNewLiveData()
factory.searchResults.test()
.also { factory.onQueryUpdated("test") }
.assertValue(liveData)
}
@Test
fun `onQueryUpdated invokes livedatconverter with no items emitter`() {
val (captor, _) = expectNewLiveData()
factory.onQueryUpdated("test")
factory.noItemsLoadedEvent.test()
.also { captor.firstValue.invoke() }
.assertValue(Unit)
}
/*
* Just for coverage, no way to really assert this
* */
@Test
fun `retryFailedRequest does nothing without a factory`() {
factory.retryFailedRequest()
}
@Test
fun `retryFailedRequest retries with a factory`() {
val (_, _, dataSourceFactory) = expectNewLiveData()
factory.onQueryUpdated("test")
factory.retryFailedRequest()
verify(dataSourceFactory).retryFailedRequest()
}
private fun expectNewLiveData(): Triple<KArgumentCaptor<() -> Unit>, LiveData<PagedList<DepictedItem>>, SearchDepictionsDataSourceFactory> {
val dataSourceFactory: SearchDepictionsDataSourceFactory = mock()
whenever(
searchDepictionsDataSourceFactoryFactory.create(
"test",
factory.loadingStates as PublishProcessor<LoadingState>
)
).thenReturn(dataSourceFactory)
val captor = argumentCaptor<() -> Unit>()
val liveData: LiveData<PagedList<DepictedItem>> = mock()
whenever(liveDataConverter.convert(eq(dataSourceFactory), captor.capture()))
.thenReturn(liveData)
return Triple(captor, liveData, dataSourceFactory)
}
}