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/build.gradle b/app/build.gradle index 9520726a1..1464de56b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -43,10 +43,6 @@ dependencies { implementation 'com.karumi:dexter:5.0.0' implementation "com.jakewharton:butterknife:$BUTTERKNIFE_VERSION" - //paging - implementation "androidx.paging:paging-runtime-ktx:2.1.2" - implementation "androidx.paging:paging-rxjava2-ktx:2.1.2" - kapt "com.jakewharton:butterknife-compiler:$BUTTERKNIFE_VERSION" implementation "com.hannesdorfmann:adapterdelegates4-kotlin-dsl-layoutcontainer:$ADAPTER_DELEGATES_VERSION" implementation "com.hannesdorfmann:adapterdelegates4-pagination:$ADAPTER_DELEGATES_VERSION" 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/FooterAdapter.kt b/app/src/main/java/fr/free/nrw/commons/explore/FooterAdapter.kt new file mode 100644 index 000000000..f51dad4e6 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/FooterAdapter.kt @@ -0,0 +1,54 @@ +package fr.free.nrw.commons.explore + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.LayoutRes +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import fr.free.nrw.commons.R +import kotlinx.android.extensions.LayoutContainer +import kotlinx.android.synthetic.main.list_item_load_more.* + +class FooterAdapter(private val onRefreshClicked: () -> Unit) : + ListAdapter(object : + DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: FooterItem, newItem: FooterItem) = oldItem == newItem + + override fun areContentsTheSame(oldItem: FooterItem, newItem: FooterItem) = + oldItem == newItem + }) { + + override fun getItemViewType(position: Int): Int { + return getItem(position).ordinal + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + when (FooterItem.values()[viewType]) { + FooterItem.LoadingItem -> LoadingViewHolder(parent.inflate(R.layout.list_item_progress)) + FooterItem.RefreshItem -> RefreshViewHolder( + parent.inflate(R.layout.list_item_load_more), + onRefreshClicked + ) + } + + override fun onBindViewHolder(holder: FooterViewHolder, position: Int) {} +} + +open class FooterViewHolder(override val containerView: View) : + RecyclerView.ViewHolder(containerView), + LayoutContainer + +class LoadingViewHolder(containerView: View) : FooterViewHolder(containerView) +class RefreshViewHolder(containerView: View, onRefreshClicked: () -> Unit) : + FooterViewHolder(containerView) { + init { + listItemLoadMoreButton.setOnClickListener { onRefreshClicked() } + } +} + +enum class FooterItem { LoadingItem, RefreshItem } + +fun ViewGroup.inflate(@LayoutRes layoutId: Int, attachToRoot: Boolean = false): View = + LayoutInflater.from(context).inflate(layoutId, this, attachToRoot) 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/SearchDataSource.kt b/app/src/main/java/fr/free/nrw/commons/explore/SearchDataSource.kt new file mode 100644 index 000000000..9bda6d26f --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/SearchDataSource.kt @@ -0,0 +1,67 @@ +package fr.free.nrw.commons.explore + +import androidx.paging.PositionalDataSource +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 + +abstract class SearchDataSource( + private val loadingStates: PublishProcessor +) : PositionalDataSource() { + + private var lastExecutedRequest: (() -> Boolean)? = null + private fun storeAndExecute(function: () -> Boolean) { + function.also { lastExecutedRequest = it }.invoke() + } + + private fun performWithTryCatch(function: () -> Unit) = try { + function.invoke() + loadingStates.offer(LoadingState.Complete) + } catch (e: Exception) { + 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/SearchDataSourceFactory.kt b/app/src/main/java/fr/free/nrw/commons/explore/SearchDataSourceFactory.kt new file mode 100644 index 000000000..7a53572e7 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/SearchDataSourceFactory.kt @@ -0,0 +1,92 @@ +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.explore.depictions.LoadFunction +import fr.free.nrw.commons.explore.depictions.LoadingStates +import io.reactivex.Flowable +import io.reactivex.processors.PublishProcessor +import javax.inject.Inject + +private const val PAGE_SIZE = 50 +private const val INITIAL_LOAD_SIZE = 50 + +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 _noItemsLoadedEvent = PublishProcessor.create() + val noItemsLoadedEvent: Flowable = _noItemsLoadedEvent + private var currentFactory: SearchDataSourceFactory? = null + + abstract val loadFunction: LoadFunction + + fun onQueryUpdated(query: String) { + this.query = query + _searchResults.offer( + liveDataConverter.convert(dataSourceFactoryFactory().also { currentFactory = it }) { + _noItemsLoadedEvent.offer(Unit) + } + ) + } + + fun retryFailedRequest() { + currentFactory?.retryFailedRequest() + } +} + +class LiveDataConverter @Inject constructor() { + fun convert( + dataSourceFactory: SearchDataSourceFactory, + zeroItemsLoadedFunction: () -> Unit + ): LiveData> { + return dataSourceFactory.toLiveData( + Config( + pageSize = PAGE_SIZE, + initialLoadSizeHint = INITIAL_LOAD_SIZE, + enablePlaceholders = false + ), + boundaryCallback = object : PagedList.BoundaryCallback() { + override fun onZeroItemsLoaded() { + zeroItemsLoadedFunction() + } + } + ) + } + +} + +abstract class SearchDataSourceFactory(val loadingStates: LoadingStates) : + DataSource.Factory() { + private var currentDataSource: SearchDataSource? = null + abstract val loadFunction: LoadFunction + + 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() + object Complete : LoadingState() + object Error : 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 460fe4c9d..f50e41764 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 @@ -6,6 +6,7 @@ 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.inflate import fr.free.nrw.commons.upload.structure.depictions.DepictedItem import kotlinx.android.extensions.LayoutContainer import kotlinx.android.synthetic.main.item_depictions.* @@ -19,9 +20,7 @@ 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)) 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/java/fr/free/nrw/commons/media/MediaDetailFragment.java b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.java index e3a132b81..d78c0026b 100644 --- a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.java @@ -43,6 +43,7 @@ import fr.free.nrw.commons.MediaDataExtractor; import fr.free.nrw.commons.R; import fr.free.nrw.commons.Utils; import fr.free.nrw.commons.auth.AccountUtil; +import fr.free.nrw.commons.category.CategoryClient; import fr.free.nrw.commons.category.CategoryDetailsActivity; import fr.free.nrw.commons.contributions.ContributionsFragment; import fr.free.nrw.commons.delete.DeleteHelper; diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java b/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java index 5173d66f9..9094d8547 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java @@ -34,7 +34,6 @@ import android.widget.RelativeLayout; import android.widget.SearchView; import android.widget.TextView; import android.widget.Toast; - import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; diff --git a/app/src/main/res/layout/fragment_search_paginated.xml b/app/src/main/res/layout/fragment_search_paginated.xml new file mode 100644 index 000000000..265c6914c --- /dev/null +++ b/app/src/main/res/layout/fragment_search_paginated.xml @@ -0,0 +1,29 @@ + + + + + + + + + + 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/SearchDataSourceFactoryTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/explore/SearchDataSourceFactoryTest.kt new file mode 100644 index 000000000..bbe6e9991 --- /dev/null +++ b/app/src/test/kotlin/fr/free/nrw/commons/explore/SearchDataSourceFactoryTest.kt @@ -0,0 +1,58 @@ +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.instanceOf +import org.junit.Before +import org.junit.Ignore +import org.junit.Test +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.MockitoAnnotations + +class SearchDataSourceFactoryTest { + + @Mock + private lateinit var depictsClient: DepictsClient + + @Mock + private lateinit var loadingStates: PublishProcessor + private lateinit var factory: SearchDataSourceFactory + + private var function: (Int, Int) -> List = mock() + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + factory = object : SearchDataSourceFactory(loadingStates) { + override val loadFunction get() = function + } + } + + @Test + fun `create returns a dataSource`() { + assertThat( + factory.create(), + instanceOf(SearchDataSource::class.java) + ) + } + + @Test + @Ignore("Rewrite with Mockk constructor mocks") + fun `retryFailedRequest invokes method if not null`() { + val spyFactory = spy(factory) + val dataSource = mock>() + Mockito.doReturn(dataSource).`when`(spyFactory).create() + factory.retryFailedRequest() + verify(dataSource).retryFailedRequest() + } + + @Test + fun `retryFailedRequest does not invoke method if null`() { + factory.retryFailedRequest() + } +} diff --git a/app/src/test/kotlin/fr/free/nrw/commons/explore/SearchDataSourceTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/explore/SearchDataSourceTest.kt new file mode 100644 index 000000000..8254e517d --- /dev/null +++ b/app/src/test/kotlin/fr/free/nrw/commons/explore/SearchDataSourceTest.kt @@ -0,0 +1,120 @@ +package fr.free.nrw.commons.explore + +import androidx.paging.PositionalDataSource +import com.nhaarman.mockitokotlin2.* +import fr.free.nrw.commons.explore.depictions.LoadingStates +import io.reactivex.plugins.RxJavaPlugins +import io.reactivex.processors.PublishProcessor +import io.reactivex.schedulers.Schedulers +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.MockitoAnnotations + +class SearchDataSourceTest { + + private lateinit var loadingStates: PublishProcessor + private lateinit var searchDepictionsDataSource: TestSearchDataSource + + @Mock + private lateinit var mockGetItems: MockGetItems + + @Before + fun setUp() { + RxJavaPlugins.setIoSchedulerHandler { Schedulers.trampoline() } + MockitoAnnotations.initMocks(this) + loadingStates = PublishProcessor.create() + searchDepictionsDataSource = + TestSearchDataSource( + loadingStates, + mockGetItems + ) + } + + @After + fun tearDown() { + RxJavaPlugins.reset() + } + + @Test + fun `loadInitial returns results and emits InitialLoad & Complete`() { + val params = PositionalDataSource.LoadInitialParams(0, 1, 2, false) + 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(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(mockGetItems.getItems(1, 0)).thenThrow(RuntimeException()) + val testSubscriber = loadingStates.test() + searchDepictionsDataSource.loadInitial(params, callback) + verify(callback, never()).onResult(any(), any()) + testSubscriber.assertValues(LoadingState.InitialLoad, LoadingState.Error) + } + + @Test + fun `loadRange returns results and emits Loading & Complete`() { + val callback: PositionalDataSource.LoadRangeCallback = mock() + val params = PositionalDataSource.LoadRangeParams(0, 1) + whenever(mockGetItems.getItems(params.loadSize, params.startPosition)) + .thenReturn(emptyList()) + val testSubscriber = loadingStates.test() + searchDepictionsDataSource.loadRange(params, callback) + verify(callback).onResult(emptyList()) + testSubscriber.assertValues(LoadingState.Loading, LoadingState.Complete) + } + + @Test + fun `loadRange onError does not return results and emits Loading & Error`() { + val callback: PositionalDataSource.LoadRangeCallback = mock() + val params = PositionalDataSource.LoadRangeParams(0, 1) + whenever(mockGetItems.getItems(params.loadSize, params.startPosition)) + .thenThrow(RuntimeException()) + val testSubscriber = loadingStates.test() + searchDepictionsDataSource.loadRange(params, callback) + verify(callback, never()).onResult(any()) + testSubscriber.assertValues(LoadingState.Loading, LoadingState.Error) + } + + @Test + fun `retryFailedRequest does nothing when null`() { + searchDepictionsDataSource.retryFailedRequest() + verifyNoMoreInteractions(mockGetItems) + } + + @Test + fun `retryFailedRequest retries last request`() { + val callback: PositionalDataSource.LoadRangeCallback = mock() + val params = PositionalDataSource.LoadRangeParams(0, 1) + 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( + 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())) + } +} +