diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index ccc2da979..405537524 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -7,13 +7,6 @@ - - - @@ -248,6 +241,28 @@ +
+ + + + .*:name + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + name + + ^$ + + + +
@@ -275,211 +290,12 @@ - .*:.*Style - - http://schemas.android.com/apk/res/android - - - BY_NAME - -
-
- - - - .*:layout_width - - http://schemas.android.com/apk/res/android - - - -
-
- - - - .*:layout_height - - http://schemas.android.com/apk/res/android - - - -
-
- - - - .*:layout_weight - - http://schemas.android.com/apk/res/android - - - -
-
- - - - .*:layout_margin - - http://schemas.android.com/apk/res/android - - - -
-
- - - - .*:layout_marginTop - - http://schemas.android.com/apk/res/android - - - -
-
- - - - .*:layout_marginBottom - - http://schemas.android.com/apk/res/android - - - -
-
- - - - .*:layout_marginStart - - http://schemas.android.com/apk/res/android - - - -
-
- - - - .*:layout_marginEnd - - http://schemas.android.com/apk/res/android - - - -
-
- - - - .*:layout_marginLeft - - http://schemas.android.com/apk/res/android - - - -
-
- - - - .*:layout_marginRight - - http://schemas.android.com/apk/res/android - - - -
-
- - - - .*:layout_.* - - http://schemas.android.com/apk/res/android - - - BY_NAME - -
-
- - - - .*:padding - - http://schemas.android.com/apk/res/android - - - -
-
- - - - .*:paddingTop - - http://schemas.android.com/apk/res/android - - - -
-
- - - - .*:paddingBottom - - http://schemas.android.com/apk/res/android - - - -
-
- - - - .*:paddingStart - - http://schemas.android.com/apk/res/android - - - -
-
- - - - .*:paddingEnd - - http://schemas.android.com/apk/res/android - - - -
-
- - - - .*:paddingLeft - - http://schemas.android.com/apk/res/android - - - -
-
- - - - .*:paddingRight + .* http://schemas.android.com/apk/res/android + ANDROID_ATTRIBUTE_ORDER
@@ -487,39 +303,7 @@ .* - http://schemas.android.com/apk/res/android - - - BY_NAME - -
-
- - - - .* - http://schemas.android.com/apk/res-auto - - - BY_NAME - -
-
- - - - .* - http://schemas.android.com/tools - - - BY_NAME - -
-
- - - - .* + .* diff --git a/app/src/main/java/fr/free/nrw/commons/explore/BaseSearchFragment.kt b/app/src/main/java/fr/free/nrw/commons/explore/BaseSearchFragment.kt new file mode 100644 index 000000000..62e6049d8 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/BaseSearchFragment.kt @@ -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 : CommonsDaggerSupportFragment(), + SearchFragmentContract.View { + + abstract val pagedListAdapter: PagedListAdapter + abstract val injectedPresenter: SearchFragmentContract.Presenter + abstract val emptyTemplateTextId: Int + private val loadingAdapter by lazy { FooterAdapter { injectedPresenter.retryFailedRequest() } } + private val mergeAdapter by lazy { MergeAdapter(pagedListAdapter, loadingAdapter) } + private var searchResults: LiveData>? = 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>) { + 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 diff --git a/app/src/main/java/fr/free/nrw/commons/explore/BaseSearchPresenter.kt b/app/src/main/java/fr/free/nrw/commons/explore/BaseSearchPresenter.kt new file mode 100644 index 000000000..3f2bdfbb7 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/BaseSearchPresenter.kt @@ -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( + val mainThreadScheduler: Scheduler, + val pageableDataSource: PageableDataSource +) : SearchFragmentContract.Presenter { + + private val DUMMY: SearchFragmentContract.View = proxy() + private var view: SearchFragmentContract.View = DUMMY + private var currentQuery: String? = null + + + private val compositeDisposable = CompositeDisposable() + override val listFooterData = MutableLiveData>().apply { value = emptyList() } + + override fun onAttachView(view: SearchFragmentContract.View) { + 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) + } + +} diff --git a/app/src/main/java/fr/free/nrw/commons/explore/BaseViewHolder.kt b/app/src/main/java/fr/free/nrw/commons/explore/BaseViewHolder.kt new file mode 100644 index 000000000..98115046f --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/BaseViewHolder.kt @@ -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(override val containerView: View) : + RecyclerView.ViewHolder(containerView), LayoutContainer { + abstract fun bind(item: T) +} diff --git a/app/src/main/java/fr/free/nrw/commons/explore/depictions/FooterAdapter.kt b/app/src/main/java/fr/free/nrw/commons/explore/FooterAdapter.kt similarity index 97% rename from app/src/main/java/fr/free/nrw/commons/explore/depictions/FooterAdapter.kt rename to app/src/main/java/fr/free/nrw/commons/explore/FooterAdapter.kt index 76d4be40a..f51dad4e6 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/depictions/FooterAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/explore/FooterAdapter.kt @@ -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 diff --git a/app/src/main/java/fr/free/nrw/commons/explore/SearchActivity.java b/app/src/main/java/fr/free/nrw/commons/explore/SearchActivity.java index 1273bb2c4..39ce648fd 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/SearchActivity.java +++ b/app/src/main/java/fr/free/nrw/commons/explore/SearchActivity.java @@ -125,7 +125,7 @@ public class SearchActivity extends NavigationBaseActivity } if (FragmentUtils.isFragmentUIActive(searchCategoryFragment)) { - searchCategoryFragment.updateCategoryList(query.toString()); + searchCategoryFragment.onQueryUpdated(query.toString()); } } else { diff --git a/app/src/main/java/fr/free/nrw/commons/explore/SearchCategoriesFragmentPresenter.kt b/app/src/main/java/fr/free/nrw/commons/explore/SearchCategoriesFragmentPresenter.kt new file mode 100644 index 000000000..3de2c9778 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/SearchCategoriesFragmentPresenter.kt @@ -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(mainThreadScheduler, dataSourceFactory), + SearchCategoriesFragmentContract.Presenter diff --git a/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsDataSource.kt b/app/src/main/java/fr/free/nrw/commons/explore/SearchDataSource.kt similarity index 61% rename from app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsDataSource.kt rename to app/src/main/java/fr/free/nrw/commons/explore/SearchDataSource.kt index 413ee017e..9bda6d26f 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsDataSource.kt +++ b/app/src/main/java/fr/free/nrw/commons/explore/SearchDataSource.kt @@ -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, - private val query: String -) : PositionalDataSource() { +abstract class SearchDataSource( + private val loadingStates: PublishProcessor +) : PositionalDataSource() { private var lastExecutedRequest: (() -> Boolean)? = null - - override fun loadInitial( - params: LoadInitialParams, - callback: LoadInitialCallback - ) { - storeAndExecute { - loadingStates.offer(LoadingState.InitialLoad) - performWithTryCatch { - callback.onResult( - getItems(query, params.requestedLoadSize, params.requestedStartPosition), - params.requestedStartPosition - ) - } - } - } - - override fun loadRange(params: LoadRangeParams, callback: LoadRangeCallback) { - 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 + ) { + storeAndExecute { + loadingStates.offer(LoadingState.InitialLoad) + performWithTryCatch { + callback.onResult( + getItems(params.requestedLoadSize, params.requestedStartPosition), + params.requestedStartPosition + ) + } + } + } + + override fun loadRange(params: LoadRangeParams, callback: LoadRangeCallback) { + storeAndExecute { + loadingStates.offer(LoadingState.Loading) + performWithTryCatch { + callback.onResult(getItems(params.loadSize, params.startPosition)) + } + } + } + + protected abstract fun getItems(loadSize: Int, startPosition: Int): List + + fun retryFailedRequest() { + Completable.fromAction { lastExecutedRequest?.invoke() } + .subscribeOn(Schedulers.io()) + .subscribe() + } +} + +fun dataSource( + loadingStates: PublishProcessor, + loadFunction: LoadFunction +) = object : SearchDataSource(loadingStates) { + override fun getItems(loadSize: Int, startPosition: Int): List { + return loadFunction(loadSize, startPosition) + } } diff --git a/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchableDepictionsDataSourceFactory.kt b/app/src/main/java/fr/free/nrw/commons/explore/SearchDataSourceFactory.kt similarity index 52% rename from app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchableDepictionsDataSourceFactory.kt rename to app/src/main/java/fr/free/nrw/commons/explore/SearchDataSourceFactory.kt index 90bdcdc40..7a53572e7 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchableDepictionsDataSourceFactory.kt +++ b/app/src/main/java/fr/free/nrw/commons/explore/SearchDataSourceFactory.kt @@ -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(private val liveDataConverter: LiveDataConverter) { + + lateinit var query: String + private val dataSourceFactoryFactory: () -> SearchDataSourceFactory = { + dataSourceFactory(_loadingStates, loadFunction) + } private val _loadingStates = PublishProcessor.create() val loadingStates: Flowable = _loadingStates - private val _searchResults = PublishProcessor.create>>() - val searchResults: Flowable>> = _searchResults + private val _searchResults = PublishProcessor.create>>() + val searchResults: Flowable>> = _searchResults private val _noItemsLoadedEvent = PublishProcessor.create() val noItemsLoadedEvent: Flowable = _noItemsLoadedEvent + private var currentFactory: SearchDataSourceFactory? = null - private var currentFactory: SearchDepictionsDataSourceFactory? = null + abstract val loadFunction: LoadFunction 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 convert( + dataSourceFactory: SearchDataSourceFactory, zeroItemsLoadedFunction: () -> Unit - ): LiveData> { + ): LiveData> { return dataSourceFactory.toLiveData( Config( pageSize = PAGE_SIZE, initialLoadSizeHint = INITIAL_LOAD_SIZE, enablePlaceholders = false ), - boundaryCallback = object : PagedList.BoundaryCallback() { + boundaryCallback = object : PagedList.BoundaryCallback() { override fun onZeroItemsLoaded() { zeroItemsLoadedFunction() } @@ -61,25 +65,25 @@ class LiveDataConverter @Inject constructor() { } -interface SearchDepictionsDataSourceFactoryFactory { - fun create(query: String, loadingStates: PublishProcessor) - : SearchDepictionsDataSourceFactory -} +abstract class SearchDataSourceFactory(val loadingStates: LoadingStates) : + DataSource.Factory() { + private var currentDataSource: SearchDataSource? = null + abstract val loadFunction: LoadFunction -class SearchDepictionsDataSourceFactory constructor( - private val depictsClient: DepictsClient, - private val query: String, - private val loadingStates: PublishProcessor -) : DataSource.Factory() { - 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 dataSourceFactory(loadingStates: LoadingStates, loadFunction: LoadFunction) = + object : SearchDataSourceFactory(loadingStates) { + override val loadFunction: LoadFunction = loadFunction + } + sealed class LoadingState { object InitialLoad : LoadingState() object Loading : LoadingState() diff --git a/app/src/main/java/fr/free/nrw/commons/explore/SearchFragmentContract.kt b/app/src/main/java/fr/free/nrw/commons/explore/SearchFragmentContract.kt new file mode 100644 index 000000000..19507a303 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/SearchFragmentContract.kt @@ -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 { + fun showSnackbar() + fun observeSearchResults(searchResults: LiveData>) + fun setEmptyViewText(query: String) + fun showInitialLoadInProgress() + fun hideInitialLoadProgress() + } + + interface Presenter : BasePresenter> { + val listFooterData: LiveData> + fun onQueryUpdated(query: String) + fun retryFailedRequest() + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/explore/SearchModule.java b/app/src/main/java/fr/free/nrw/commons/explore/SearchModule.java deleted file mode 100644 index 4b1fa5326..000000000 --- a/app/src/main/java/fr/free/nrw/commons/explore/SearchModule.java +++ /dev/null @@ -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); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/explore/SearchModule.kt b/app/src/main/java/fr/free/nrw/commons/explore/SearchModule.kt new file mode 100644 index 000000000..22ac34939 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/SearchModule.kt @@ -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? + +} diff --git a/app/src/main/java/fr/free/nrw/commons/explore/categories/PageableCategoriesDataSource.kt b/app/src/main/java/fr/free/nrw/commons/explore/categories/PageableCategoriesDataSource.kt new file mode 100644 index 000000000..67caaf1ab --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/categories/PageableCategoriesDataSource.kt @@ -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(liveDataConverter) { + + override val loadFunction = { loadSize: Int, startPosition: Int -> + categoryClient.searchCategories(query, loadSize, startPosition).blockingFirst() + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/explore/categories/PagedSearchCategoriesAdapter.kt b/app/src/main/java/fr/free/nrw/commons/explore/categories/PagedSearchCategoriesAdapter.kt new file mode 100644 index 000000000..52eb49165 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/categories/PagedSearchCategoriesAdapter.kt @@ -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( + object : DiffUtil.ItemCallback() { + 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(containerView) { + + override fun bind(item: String) { + containerView.setOnClickListener { onCategoryClicked(item) } + textView1.text = item.substringAfter(CATEGORY_PREFIX) + } +} + + diff --git a/app/src/main/java/fr/free/nrw/commons/explore/categories/SearchCategoriesFragmentContract.kt b/app/src/main/java/fr/free/nrw/commons/explore/categories/SearchCategoriesFragmentContract.kt new file mode 100644 index 000000000..97defd543 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/categories/SearchCategoriesFragmentContract.kt @@ -0,0 +1,8 @@ +package fr.free.nrw.commons.explore.categories + +import fr.free.nrw.commons.explore.SearchFragmentContract + +interface SearchCategoriesFragmentContract { + interface View : SearchFragmentContract.View + interface Presenter : SearchFragmentContract.Presenter +} diff --git a/app/src/main/java/fr/free/nrw/commons/explore/categories/SearchCategoryFragment.java b/app/src/main/java/fr/free/nrw/commons/explore/categories/SearchCategoryFragment.java deleted file mode 100644 index 855461e1d..000000000 --- a/app/src/main/java/fr/free/nrw/commons/explore/categories/SearchCategoryFragment.java +++ /dev/null @@ -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 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 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 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); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/explore/categories/SearchCategoryFragment.kt b/app/src/main/java/fr/free/nrw/commons/explore/categories/SearchCategoryFragment.kt new file mode 100644 index 000000000..aa5cae887 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/categories/SearchCategoryFragment.kt @@ -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() { + @Inject + lateinit var presenter: SearchCategoriesFragmentContract.Presenter + + override val emptyTemplateTextId: Int = R.string.categories_not_found + + override val injectedPresenter: SearchFragmentContract.Presenter + get() = presenter + + override val pagedListAdapter by lazy { + PagedSearchCategoriesAdapter { CategoryDetailsActivity.startYourself(context, it) } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/explore/depictions/DepictionAdapter.kt b/app/src/main/java/fr/free/nrw/commons/explore/depictions/DepictionAdapter.kt index 46dbd2bfc..8310915a1 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/depictions/DepictionAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/explore/depictions/DepictionAdapter.kt @@ -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(containerView) { + + override fun bind(item: DepictedItem) { containerView.setOnClickListener { onDepictionClicked(item) } depicts_label.text = item.name description.text = item.description diff --git a/app/src/main/java/fr/free/nrw/commons/explore/depictions/PageableDepictionsDataSource.kt b/app/src/main/java/fr/free/nrw/commons/explore/depictions/PageableDepictionsDataSource.kt new file mode 100644 index 000000000..f6abeec60 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/depictions/PageableDepictionsDataSource.kt @@ -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 = (Int, Int) -> List +typealias LoadingStates = PublishProcessor + +class PageableDepictionsDataSource @Inject constructor( + liveDataConverter: LiveDataConverter, + val depictsClient: DepictsClient +) : PageableDataSource(liveDataConverter) { + + override val loadFunction = { loadSize: Int, startPosition: Int -> + depictsClient.searchForDepictions(query, loadSize, startPosition).blockingGet() + } +} + diff --git a/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragment.kt b/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragment.kt index 7d9ec10de..0acf619fc 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragment.kt @@ -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(), 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 + 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>? = 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>) { - 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 diff --git a/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragmentContract.kt b/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragmentContract.kt index 69ade7c7b..3f4da119e 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragmentContract.kt +++ b/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragmentContract.kt @@ -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>) - fun setEmptyViewText(query: String) - fun showInitialLoadInProgress() - fun hideInitialLoadProgress() - } - - interface UserActionListener : BasePresenter { - val listFooterData: LiveData> - fun onQueryUpdated(query: String) - fun retryFailedRequest() - } + interface View : SearchFragmentContract.View + interface Presenter : SearchFragmentContract.Presenter } diff --git a/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragmentPresenter.kt b/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragmentPresenter.kt index b4d02409c..dbf0e6e63 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragmentPresenter.kt +++ b/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragmentPresenter.kt @@ -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>().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(mainThreadScheduler, dataSourceFactory), + SearchDepictionsFragmentContract.Presenter diff --git a/app/src/main/res/layout/fragment_search_depictions.xml b/app/src/main/res/layout/fragment_search_paginated.xml similarity index 73% rename from app/src/main/res/layout/fragment_search_depictions.xml rename to app/src/main/res/layout/fragment_search_paginated.xml index c87bbafcd..265c6914c 100644 --- a/app/src/main/res/layout/fragment_search_depictions.xml +++ b/app/src/main/res/layout/fragment_search_paginated.xml @@ -2,11 +2,11 @@ + android:orientation="vertical" + android:paddingTop="@dimen/tiny_gap"> + android:layout_centerInParent="true" /> diff --git a/app/src/test/kotlin/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragmentPresenterTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/explore/BaseSearchPresenterTest.kt similarity index 58% rename from app/src/test/kotlin/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragmentPresenterTest.kt rename to app/src/test/kotlin/fr/free/nrw/commons/explore/BaseSearchPresenterTest.kt index 21c4845dd..740984882 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragmentPresenterTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/explore/BaseSearchPresenterTest.kt @@ -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 - private lateinit var searchDepictionsFragmentPresenter: SearchDepictionsFragmentPresenter + private lateinit var baseSearchPresenter: BaseSearchPresenter private lateinit var testScheduler: TestScheduler @Mock - private lateinit var searchableDepictionsDataSourceFactory: SearchableDepictionsDataSourceFactory + private lateinit var pageableDataSource: PageableDataSource private var loadingStates: PublishProcessor = PublishProcessor.create() - private var searchResults: PublishProcessor>> = + private var searchResults: PublishProcessor>> = PublishProcessor.create() private var noItemLoadedEvent: PublishProcessor = 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(testScheduler, pageableDataSource) {} + baseSearchPresenter.onAttachView(view) } @Test fun `searchResults emission updates the view`() { - val pagedListLiveData = mock>>() + val pagedListLiveData = mock>>() 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) { diff --git a/app/src/test/kotlin/fr/free/nrw/commons/explore/PageableDataSourceTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/explore/PageableDataSourceTest.kt new file mode 100644 index 000000000..2ced05a27 --- /dev/null +++ b/app/src/test/kotlin/fr/free/nrw/commons/explore/PageableDataSourceTest.kt @@ -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 + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + pageableDataSource = object: PageableDataSource(liveDataConverter){ + override val loadFunction: LoadFunction + 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 Unit>, LiveData>, KArgumentCaptor>> { + val captor = argumentCaptor<() -> Unit>() + val dataSourceFactoryCaptor = argumentCaptor>() + val liveData: LiveData> = mock() + whenever(liveDataConverter.convert(dataSourceFactoryCaptor.capture(), captor.capture())) + .thenReturn(liveData) + return Triple(captor, liveData, dataSourceFactoryCaptor) + } +} diff --git a/app/src/test/kotlin/fr/free/nrw/commons/explore/depictions/SearchDepictionsDataSourceFactoryTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/explore/SearchDataSourceFactoryTest.kt similarity index 67% rename from app/src/test/kotlin/fr/free/nrw/commons/explore/depictions/SearchDepictionsDataSourceFactoryTest.kt rename to app/src/test/kotlin/fr/free/nrw/commons/explore/SearchDataSourceFactoryTest.kt index 614b3a5cb..bbe6e9991 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/explore/depictions/SearchDepictionsDataSourceFactoryTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/explore/SearchDataSourceFactoryTest.kt @@ -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 - private lateinit var factory: SearchDepictionsDataSourceFactory + private lateinit var factory: SearchDataSourceFactory + + private var function: (Int, Int) -> List = mock() @Before fun setUp() { MockitoAnnotations.initMocks(this) - factory = SearchDepictionsDataSourceFactory(depictsClient, "test", loadingStates) + factory = object : SearchDataSourceFactory(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() + val dataSource = mock>() Mockito.doReturn(dataSource).`when`(spyFactory).create() factory.retryFailedRequest() verify(dataSource).retryFailedRequest() diff --git a/app/src/test/kotlin/fr/free/nrw/commons/explore/depictions/SearchDepictionsDataSourceTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/explore/SearchDataSourceTest.kt similarity index 57% rename from app/src/test/kotlin/fr/free/nrw/commons/explore/depictions/SearchDepictionsDataSourceTest.kt rename to app/src/test/kotlin/fr/free/nrw/commons/explore/SearchDataSourceTest.kt index 50af80ea1..8254e517d 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/explore/depictions/SearchDepictionsDataSourceTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/explore/SearchDataSourceTest.kt @@ -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 - 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>() - whenever(depictsClient.searchForDepictions("test", 1, 0)) - .thenReturn(Single.just(emptyList())) + val callback = mock>() + 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>() - whenever(depictsClient.searchForDepictions("test", 1, 0)) - .thenThrow(RuntimeException()) + val callback = mock>() + 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 = mock() + val callback: PositionalDataSource.LoadRangeCallback = 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 = mock() + val callback: PositionalDataSource.LoadRangeCallback = 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 = mock() + val callback: PositionalDataSource.LoadRangeCallback = 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(loadingStates) { + override fun getItems(loadSize: Int, startPosition: Int): List = + mockGetItems.getItems(loadSize, startPosition) +} + +interface MockGetItems { + fun getItems(loadSize: Int, startPosition: Int): List +} diff --git a/app/src/test/kotlin/fr/free/nrw/commons/explore/categroies/PageableCategoriesDataSourceTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/explore/categroies/PageableCategoriesDataSourceTest.kt new file mode 100644 index 000000000..1561273c6 --- /dev/null +++ b/app/src/test/kotlin/fr/free/nrw/commons/explore/categroies/PageableCategoriesDataSourceTest.kt @@ -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())) + } +} diff --git a/app/src/test/kotlin/fr/free/nrw/commons/explore/depictions/PageableDepictionsDataSourceTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/explore/depictions/PageableDepictionsDataSourceTest.kt new file mode 100644 index 000000000..df3496c62 --- /dev/null +++ b/app/src/test/kotlin/fr/free/nrw/commons/explore/depictions/PageableDepictionsDataSourceTest.kt @@ -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())) + } +} + diff --git a/app/src/test/kotlin/fr/free/nrw/commons/explore/depictions/SearchableDepictionsDataSourceFactoryTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/explore/depictions/SearchableDepictionsDataSourceFactoryTest.kt deleted file mode 100644 index 5cabbaf78..000000000 --- a/app/src/test/kotlin/fr/free/nrw/commons/explore/depictions/SearchableDepictionsDataSourceFactoryTest.kt +++ /dev/null @@ -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 Unit>, LiveData>, SearchDepictionsDataSourceFactory> { - val dataSourceFactory: SearchDepictionsDataSourceFactory = mock() - whenever( - searchDepictionsDataSourceFactoryFactory.create( - "test", - factory.loadingStates as PublishProcessor - ) - ).thenReturn(dataSourceFactory) - val captor = argumentCaptor<() -> Unit>() - val liveData: LiveData> = mock() - whenever(liveDataConverter.convert(eq(dataSourceFactory), captor.capture())) - .thenReturn(liveData) - return Triple(captor, liveData, dataSourceFactory) - } -}