#3772 Convert SearchImagesFragment to use Pagination (#3779)

This commit is contained in:
Seán Mac Gillicuddy 2020-06-16 14:58:48 +01:00 committed by GitHub
parent e4190f3f7d
commit c77ed747fe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 386 additions and 419 deletions

View file

@ -203,7 +203,7 @@ public class Media implements Parcelable {
* @param page response from the API
* @return Media object
*/
@Nullable
@NonNull
public static Media from(final MwQueryPage page) {
final ImageInfo imageInfo = page.imageInfo();
if (imageInfo == null) {

View file

@ -12,7 +12,7 @@ import fr.free.nrw.commons.depictions.Media.DepictedImagesFragment;
import fr.free.nrw.commons.depictions.subClass.SubDepictionListFragment;
import fr.free.nrw.commons.explore.categories.SearchCategoryFragment;
import fr.free.nrw.commons.explore.depictions.SearchDepictionsFragment;
import fr.free.nrw.commons.explore.images.SearchImageFragment;
import fr.free.nrw.commons.explore.media.SearchMediaFragment;
import fr.free.nrw.commons.explore.recentsearches.RecentSearchesFragment;
import fr.free.nrw.commons.media.MediaDetailFragment;
import fr.free.nrw.commons.media.MediaDetailPagerFragment;
@ -58,7 +58,7 @@ public abstract class FragmentBuilderModule {
abstract SubCategoryListFragment bindSubCategoryListFragment();
@ContributesAndroidInjector
abstract SearchImageFragment bindBrowseImagesListFragment();
abstract SearchMediaFragment bindBrowseImagesListFragment();
@ContributesAndroidInjector
abstract SearchCategoryFragment bindSearchCategoryListFragment();

View file

@ -5,8 +5,7 @@ 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.View.*
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.LiveData
@ -24,9 +23,10 @@ import kotlinx.android.synthetic.main.fragment_search_paginated.*
abstract class BaseSearchFragment<T> : CommonsDaggerSupportFragment(),
SearchFragmentContract.View<T> {
abstract val pagedListAdapter: PagedListAdapter<T,*>
abstract val pagedListAdapter: PagedListAdapter<T, *>
abstract val injectedPresenter: SearchFragmentContract.Presenter<T>
abstract val emptyTemplateTextId: Int
abstract val errorTextId: Int
private val loadingAdapter by lazy { FooterAdapter { injectedPresenter.retryFailedRequest() } }
private val mergeAdapter by lazy { MergeAdapter(pagedListAdapter, loadingAdapter) }
private var searchResults: LiveData<PagedList<T>>? = null
@ -53,9 +53,7 @@ abstract class BaseSearchFragment<T> : CommonsDaggerSupportFragment(),
this.searchResults?.removeObservers(viewLifecycleOwner)
this.searchResults = searchResults
searchResults.observe(viewLifecycleOwner, Observer {
pagedListAdapter.submitList(it)
contentNotFound.visibility = if (it.loadedCount == 0) VISIBLE else GONE
})
pagedListAdapter.submitList(it) })
}
override fun onAttach(context: Context) {
@ -69,25 +67,30 @@ abstract class BaseSearchFragment<T> : CommonsDaggerSupportFragment(),
injectedPresenter.onDetachView()
}
override fun setEmptyViewText(query: String) {
contentNotFound.text = getString(emptyTemplateTextId, query)
}
override fun hideInitialLoadProgress() {
paginatedSearchInitialLoadProgress.visibility = View.GONE
paginatedSearchInitialLoadProgress.visibility = GONE
}
override fun showInitialLoadInProgress() {
paginatedSearchInitialLoadProgress.visibility = View.VISIBLE
paginatedSearchInitialLoadProgress.visibility = VISIBLE
}
override fun showSnackbar() {
ViewUtil.showShortSnackbar(paginatedSearchResultsList, R.string.error_loading_depictions)
ViewUtil.showShortSnackbar(paginatedSearchResultsList, errorTextId)
}
fun onQueryUpdated(query: String) {
injectedPresenter.onQueryUpdated(query)
}
override fun showEmptyText(query: String) {
contentNotFound.text = getString(emptyTemplateTextId, query)
contentNotFound.visibility = VISIBLE
}
override fun hideEmptyText() {
contentNotFound.visibility = GONE
}
}
private val Fragment.isPortrait get() = orientation == Configuration.ORIENTATION_PORTRAIT

View file

@ -14,8 +14,6 @@ abstract class BaseSearchPresenter<T>(
private val DUMMY: SearchFragmentContract.View<T> = proxy()
private var view: SearchFragmentContract.View<T> = DUMMY
private var currentQuery: String? = null
private val compositeDisposable = CompositeDisposable()
override val listFooterData = MutableLiveData<List<FooterItem>>().apply { value = emptyList() }
@ -27,31 +25,30 @@ abstract class BaseSearchPresenter<T>(
pageableDataSource.loadingStates
.observeOn(mainThreadScheduler)
.subscribe(::onLoadingState, Timber::e),
pageableDataSource.noItemsLoadedEvent.subscribe {
setEmptyViewText()
}
pageableDataSource.noItemsLoadedQueries.subscribe(view::showEmptyText)
)
}
private fun onLoadingState(it: LoadingState) = when (it) {
LoadingState.Loading -> listFooterData.postValue(listOf(FooterItem.LoadingItem))
LoadingState.Loading -> {
view.hideEmptyText()
listFooterData.postValue(listOf(FooterItem.LoadingItem))
}
LoadingState.Complete -> {
listFooterData.postValue(emptyList())
view.hideInitialLoadProgress()
}
LoadingState.InitialLoad -> view.showInitialLoadInProgress()
LoadingState.InitialLoad -> {
view.hideEmptyText()
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()
}
@ -62,7 +59,6 @@ abstract class BaseSearchPresenter<T>(
}
override fun onQueryUpdated(query: String) {
currentQuery = query
pageableDataSource.onQueryUpdated(query)
}

View file

@ -5,6 +5,7 @@ import android.text.TextUtils;
import android.view.View;
import android.widget.FrameLayout;
import android.widget.SearchView;
import androidx.annotation.NonNull;
import androidx.appcompat.widget.Toolbar;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
@ -20,7 +21,9 @@ import fr.free.nrw.commons.R;
import fr.free.nrw.commons.category.CategoryImagesCallback;
import fr.free.nrw.commons.explore.categories.SearchCategoryFragment;
import fr.free.nrw.commons.explore.depictions.SearchDepictionsFragment;
import fr.free.nrw.commons.explore.images.SearchImageFragment;
import fr.free.nrw.commons.explore.media.SearchMediaFragment;
import fr.free.nrw.commons.explore.recentsearches.RecentSearch;
import fr.free.nrw.commons.explore.recentsearches.RecentSearchesDao;
import fr.free.nrw.commons.explore.recentsearches.RecentSearchesFragment;
import fr.free.nrw.commons.media.MediaDetailPagerFragment;
import fr.free.nrw.commons.theme.NavigationBaseActivity;
@ -28,8 +31,10 @@ import fr.free.nrw.commons.utils.FragmentUtils;
import fr.free.nrw.commons.utils.ViewUtil;
import io.reactivex.android.schedulers.AndroidSchedulers;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.concurrent.TimeUnit;
import javax.inject.Inject;
import timber.log.Timber;
/**
@ -46,14 +51,16 @@ public class SearchActivity extends NavigationBaseActivity
@BindView(R.id.tab_layout) TabLayout tabLayout;
@BindView(R.id.viewPager) ViewPager viewPager;
private SearchImageFragment searchImageFragment;
@Inject
RecentSearchesDao recentSearchesDao;
private SearchMediaFragment searchMediaFragment;
private SearchCategoryFragment searchCategoryFragment;
private SearchDepictionsFragment searchDepictionsFragment;
private RecentSearchesFragment recentSearchesFragment;
private FragmentManager supportFragmentManager;
private MediaDetailPagerFragment mediaDetails;
ViewPagerAdapter viewPagerAdapter;
private String query;
@Override
protected void onCreate(Bundle savedInstanceState) {
@ -92,10 +99,10 @@ public class SearchActivity extends NavigationBaseActivity
public void setTabs() {
List<Fragment> fragmentList = new ArrayList<>();
List<String> titleList = new ArrayList<>();
searchImageFragment = new SearchImageFragment();
searchMediaFragment = new SearchMediaFragment();
searchDepictionsFragment = new SearchDepictionsFragment();
searchCategoryFragment= new SearchCategoryFragment();
fragmentList.add(searchImageFragment);
fragmentList.add(searchMediaFragment);
titleList.add(getResources().getString(R.string.search_tab_title_media).toUpperCase());
fragmentList.add(searchCategoryFragment);
titleList.add(getResources().getString(R.string.search_tab_title_categories).toUpperCase());
@ -109,9 +116,9 @@ public class SearchActivity extends NavigationBaseActivity
.debounce(500, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(query -> {
this.query = query.toString();
//update image list
//update image list
if (!TextUtils.isEmpty(query)) {
saveRecentSearch(query.toString());
viewPager.setVisibility(View.VISIBLE);
tabLayout.setVisibility(View.VISIBLE);
searchHistoryContainer.setVisibility(View.GONE);
@ -120,8 +127,8 @@ public class SearchActivity extends NavigationBaseActivity
searchDepictionsFragment.onQueryUpdated(query.toString());
}
if (FragmentUtils.isFragmentUIActive(searchImageFragment)) {
searchImageFragment.updateImageList(query.toString());
if (FragmentUtils.isFragmentUIActive(searchMediaFragment)) {
searchMediaFragment.onQueryUpdated(query.toString());
}
if (FragmentUtils.isFragmentUIActive(searchCategoryFragment)) {
@ -140,13 +147,25 @@ public class SearchActivity extends NavigationBaseActivity
));
}
private void saveRecentSearch(@NonNull final String query) {
final RecentSearch recentSearch = recentSearchesDao.find(query);
// Newly searched query...
if (recentSearch == null) {
recentSearchesDao.save(new RecentSearch(null, query, new Date()));
}
else {
recentSearch.setLastSearched(new Date());
recentSearchesDao.save(recentSearch);
}
}
/**
* returns Media Object at position
* @param i position of Media in the imagesRecyclerView adapter.
*/
@Override
public Media getMediaAtPosition(int i) {
return searchImageFragment.getImageAtPosition(i);
return searchMediaFragment.getImageAtPosition(i);
}
/**
@ -154,7 +173,7 @@ public class SearchActivity extends NavigationBaseActivity
*/
@Override
public int getTotalMediaCount() {
return searchImageFragment.getTotalImagesCount();
return searchMediaFragment.getTotalImagesCount();
}
/**
@ -207,7 +226,7 @@ public class SearchActivity extends NavigationBaseActivity
//FIXME: Temporary fix for screen rotation inside media details. If we don't call onBackPressed then fragment stack is increasing every time.
//FIXME: Similar issue like this https://github.com/commons-app/apps-android-commons/issues/894
// This is called on screen rotation when user is inside media details. Ideally it should show Media Details but since we are not saving the state now. We are throwing the user to search screen otherwise the app was crashing.
//
//
onBackPressed();
}
super.onResume();
@ -250,8 +269,8 @@ public class SearchActivity extends NavigationBaseActivity
*/
@Override
public void requestMoreImages() {
if (searchImageFragment!=null){
searchImageFragment.addImagesToList(query);
if (searchMediaFragment!=null){
searchMediaFragment.requestMoreImages();
}
}

View file

@ -24,8 +24,8 @@ abstract class PageableDataSource<T>(private val liveDataConverter: LiveDataConv
val loadingStates: Flowable<LoadingState> = _loadingStates
private val _searchResults = PublishProcessor.create<LiveData<PagedList<T>>>()
val searchResults: Flowable<LiveData<PagedList<T>>> = _searchResults
private val _noItemsLoadedEvent = PublishProcessor.create<Unit>()
val noItemsLoadedEvent: Flowable<Unit> = _noItemsLoadedEvent
private val _noItemsLoadedEvent = PublishProcessor.create<String>()
val noItemsLoadedQueries: Flowable<String> = _noItemsLoadedEvent
private var currentFactory: SearchDataSourceFactory<T>? = null
abstract val loadFunction: LoadFunction<T>
@ -34,7 +34,7 @@ abstract class PageableDataSource<T>(private val liveDataConverter: LiveDataConv
this.query = query
_searchResults.offer(
liveDataConverter.convert(dataSourceFactoryFactory().also { currentFactory = it }) {
_noItemsLoadedEvent.offer(Unit)
_noItemsLoadedEvent.offer(query)
}
)
}

View file

@ -8,9 +8,10 @@ interface SearchFragmentContract {
interface View<T> {
fun showSnackbar()
fun observeSearchResults(searchResults: LiveData<PagedList<T>>)
fun setEmptyViewText(query: String)
fun showInitialLoadInProgress()
fun hideInitialLoadProgress()
fun showEmptyText(query: String)
fun hideEmptyText()
}
interface Presenter<T> : BasePresenter<View<T>> {

View file

@ -5,6 +5,8 @@ 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
import fr.free.nrw.commons.explore.media.SearchMediaFragmentContract
import fr.free.nrw.commons.explore.media.SearchMediaFragmentPresenter
/**
* The Dagger Module for explore:depictions related presenters and (some other objects maybe in future)
@ -18,7 +20,12 @@ abstract class SearchModule {
@Binds
abstract fun bindsSearchCategoriesFragmentPresenter(
presenter: SearchCategoriesFragmentPresenter?
): SearchCategoriesFragmentContract.Presenter?
presenter: SearchCategoriesFragmentPresenter
): SearchCategoriesFragmentContract.Presenter
@Binds
abstract fun bindsSearchMediaFragmentPresenter(
presenter: SearchMediaFragmentPresenter
): SearchMediaFragmentContract.Presenter
}

View file

@ -15,6 +15,8 @@ class SearchCategoryFragment : BaseSearchFragment<String>() {
override val emptyTemplateTextId: Int = R.string.categories_not_found
override val errorTextId: Int = R.string.error_loading_categories
override val injectedPresenter: SearchFragmentContract.Presenter<String>
get() = presenter

View file

@ -17,7 +17,9 @@ class SearchDepictionsFragment : BaseSearchFragment<DepictedItem>(),
override val emptyTemplateTextId: Int = R.string.depictions_not_found
override val injectedPresenter: SearchFragmentContract.Presenter<DepictedItem>
override val errorTextId: Int = R.string.error_loading_depictions
override val injectedPresenter: SearchDepictionsFragmentContract.Presenter
get() = presenter
override val pagedListAdapter by lazy {

View file

@ -1,288 +0,0 @@
package fr.free.nrw.commons.explore.images;
import static android.view.View.GONE;
import static android.view.View.VISIBLE;
import static fr.free.nrw.commons.depictions.Media.DepictedImagesFragment.PAGE_ID_PREFIX;
import android.annotation.SuppressLint;
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.Media;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment;
import fr.free.nrw.commons.explore.SearchActivity;
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.media.MediaClient;
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 image search screen.
*/
public class SearchImageFragment extends CommonsDaggerSupportFragment {
@BindView(R.id.imagesListBox)
RecyclerView imagesRecyclerView;
@BindView(R.id.imageSearchInProgress)
ProgressBar progressBar;
@BindView(R.id.imagesNotFound)
TextView imagesNotFoundView;
String query;
@BindView(R.id.bottomProgressBar)
ProgressBar bottomProgressBar;
@Inject RecentSearchesDao recentSearchesDao;
@Inject
MediaClient mediaClient;
@Inject
@Named("default_preferences")
JsonKvStore defaultKvStore;
/**
* A variable to store number of list items for whom API has been called to fetch captions
*/
private int mediaSize = 0;
private SearchImagesAdapter imagesAdapter;
private List<Media> 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 (getContext().getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT){
imagesRecyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
}
else{
imagesRecyclerView.setLayoutManager(new GridLayoutManager(getContext(), 2));
}
imagesAdapter =new SearchImagesAdapter(media -> {
((SearchActivity)getContext()).onSearchImageClicked(imagesAdapter.getItems().indexOf(media));
saveQuery(query);
return Unit.INSTANCE;
});
imagesRecyclerView.setAdapter(imagesAdapter);
imagesRecyclerView.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)) {
addImagesToList(query);
}
}
});
return rootView;
}
/**
* Checks for internet connection and then initializes the recycler view with 25 images of the searched query
* Clearing imageAdapter every time new keyword is searched so that user can see only new results
*/
@SuppressLint("CheckResult")
public void updateImageList(String query) {
this.query = query;
if (imagesNotFoundView != null) {
imagesNotFoundView.setVisibility(GONE);
}
if (!NetworkUtils.isInternetConnectionEstablished(getContext())) {
handleNoInternet();
return;
}
progressBar.setVisibility(View.VISIBLE);
bottomProgressBar.setVisibility(GONE);
queryList.clear();
imagesAdapter.clear();
compositeDisposable.add(mediaClient.getMediaListFromSearch(query)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doOnSubscribe(disposable -> saveQuery(query))
.subscribe(this::handleSuccess, this::handleError));
}
/**
* Adds more results to existing search results
*/
@SuppressLint("CheckResult")
public void addImagesToList(String query) {
this.query = query;
bottomProgressBar.setVisibility(View.VISIBLE);
progressBar.setVisibility(GONE);
compositeDisposable.add(mediaClient.getMediaListFromSearch(query)
.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
* @param mediaList List of media to be added
*/
private void handlePaginationSuccess(List<Media> mediaList) {
progressBar.setVisibility(View.GONE);
bottomProgressBar.setVisibility(GONE);
if (mediaList.size() != 0 && !queryList.get(queryList.size() - 1).getFilename().equals(mediaList.get(mediaList.size() - 1).getFilename())) {
queryList.addAll(mediaList);
imagesAdapter.addAll(mediaList);
((SearchActivity) getContext()).viewPagerNotifyDataSetChanged();
}
}
/**
* Handles the success scenario
* it initializes the recycler view by adding items to the adapter
* @param mediaList List of media to be shown
*/
private void handleSuccess(List<Media> mediaList) {
queryList = mediaList;
if (mediaList == null || mediaList.isEmpty()) {
initErrorView();
}
else {
bottomProgressBar.setVisibility(View.GONE);
progressBar.setVisibility(GONE);
imagesAdapter.addAll(mediaList);
imagesAdapter.notifyDataSetChanged();
((SearchActivity)getContext()).viewPagerNotifyDataSetChanged();
for (Media m : mediaList) {
final String pageId = m.getPageId();
if (pageId != null) {
replaceTitlesWithCaptions(PAGE_ID_PREFIX + pageId, mediaSize++);
}
}
}
}
/**
* In explore we first show title and simultaneously call the API to retrieve captions
* When captions are retrieved they replace title
*/
public void replaceTitlesWithCaptions(String wikibaseIdentifier, int position) {
compositeDisposable.add(mediaClient.getCaptionByWikibaseIdentifier(wikibaseIdentifier)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(subscriber -> {
handleLabelforImage(subscriber, position);
}));
}
private void handleLabelforImage(String s, int position) {
if (!s.trim().equals(getString(R.string.detail_caption_empty))) {
imagesAdapter.updateThumbnail(position, s);
}
}
/**
* Logs and handles API error scenario
* @param throwable
*/
private void handleError(Throwable throwable) {
Timber.e(throwable, "Error occurred while loading queried images");
try {
ViewUtil.showShortSnackbar(imagesRecyclerView, R.string.error_loading_images);
}catch (Exception e){
e.printStackTrace();
}
}
/**
* Handles the UI updates for a error scenario
*/
private void initErrorView() {
progressBar.setVisibility(GONE);
imagesNotFoundView.setVisibility(VISIBLE);
imagesNotFoundView.setText(getString(R.string.images_not_found,query));
}
/**
* Handles the UI updates for no internet scenario
*/
private void handleNoInternet() {
if (null
!= getView()) {//We have exposed public methods to update our ui, we will have to add null checks until we make this lifecycle aware
if (null != progressBar) {
progressBar.setVisibility(GONE);
}
ViewUtil.showShortSnackbar(imagesRecyclerView, R.string.no_internet);
} else {
Timber.d("Attempt to update fragment ui after its view was destroyed");
}
}
/**
* returns total number of images present in the recyclerview adapter.
*/
public int getTotalImagesCount(){
if (imagesAdapter == null) {
return 0;
}
else {
return imagesAdapter.getItemCount();
}
}
/**
* returns Media Object at position
* @param i position of Media in the recyclerview adapter.
*/
public Media getImageAtPosition(int i) {
if (imagesAdapter.getItemAt(i).getFilename() == null) {
// not yet ready to return data
return null;
}
return imagesAdapter.getItemAt(i);
}
@Override public void onDestroyView() {
super.onDestroyView();
compositeDisposable.clear();
}
}

View file

@ -1,27 +0,0 @@
package fr.free.nrw.commons.explore.images
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
import fr.free.nrw.commons.Media
class SearchImagesAdapter(onImageClicked: (Media) -> Unit) : ListDelegationAdapter<List<Media>>(
searchImagesAdapter(onImageClicked)
) {
fun getItemAt(position: Int) = items[position]
init {
items = emptyList()
}
fun clear() {
items = emptyList()
}
fun addAll(mediaList: List<Media>) {
items = items + mediaList
}
fun updateThumbnail(position: Int, thumbnailTitle: String) {
items[position].thumbnailTitle = thumbnailTitle
notifyItemChanged(position)
}
}

View file

@ -1,24 +0,0 @@
package fr.free.nrw.commons.explore.images
import android.view.View
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateLayoutContainer
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.R
import kotlinx.android.synthetic.main.layout_category_images.*
fun searchImagesAdapter(onImageClicked: (Media) -> Unit) =
adapterDelegateLayoutContainer<Media, Media>(R.layout.layout_category_images) {
categoryImageView.setOnClickListener { onImageClicked(item) }
bind {
categoryImageTitle.text = item.thumbnailTitle
categoryImageView.setImageURI(item.thumbUrl)
if (item.creator?.isNotEmpty() == true) {
categoryImageAuthor.visibility = View.VISIBLE
categoryImageAuthor.text = getString(R.string.image_uploaded_by, item.creator)
} else {
categoryImageAuthor.visibility = View.GONE
}
}
}

View file

@ -0,0 +1,9 @@
package fr.free.nrw.commons.explore.media
import fr.free.nrw.commons.Media
import org.wikipedia.dataclient.mwapi.MwQueryPage
import javax.inject.Inject
class MediaConverter @Inject constructor() {
fun convert(mwQueryPage: MwQueryPage): Media = Media.from(mwQueryPage)
}

View file

@ -0,0 +1,34 @@
package fr.free.nrw.commons.explore.media
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.depictions.Media.DepictedImagesFragment.PAGE_ID_PREFIX
import fr.free.nrw.commons.explore.LiveDataConverter
import fr.free.nrw.commons.explore.PageableDataSource
import fr.free.nrw.commons.explore.depictions.LoadFunction
import fr.free.nrw.commons.media.MediaClient
import fr.free.nrw.commons.media.MediaClient.NO_CAPTION
import javax.inject.Inject
class PageableMediaDataSource @Inject constructor(
liveDataConverter: LiveDataConverter,
private val mediaConverter: MediaConverter,
private val mediaClient: MediaClient
) : PageableDataSource<Media>(liveDataConverter) {
override val loadFunction: LoadFunction<Media> = { loadSize: Int, startPosition: Int ->
mediaClient.getMediaListFromSearch(query, loadSize, startPosition)
.map { it.query()?.pages()?.map(mediaConverter::convert) ?: emptyList() }
.map { it.zip(getCaptions(it)) }
.map { it.map { (media, caption) -> media.also { it.caption = caption } } }
.blockingGet()
}
private fun getCaptions(it: List<Media>) =
mediaClient.getEntities(it.joinToString("|") { PAGE_ID_PREFIX + it.pageId })
.map {
it.entities().values.map { entity ->
entity.labels().values.firstOrNull()?.value() ?: NO_CAPTION
}
}
.blockingGet()
}

View file

@ -0,0 +1,49 @@
package fr.free.nrw.commons.explore.media
import android.view.View
import android.view.ViewGroup
import androidx.paging.PagedListAdapter
import androidx.recyclerview.widget.DiffUtil
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.R
import fr.free.nrw.commons.explore.BaseViewHolder
import fr.free.nrw.commons.explore.inflate
import kotlinx.android.synthetic.main.layout_category_images.*
class SearchImagesAdapter(private val onImageClicked: (Int) -> Unit) :
PagedListAdapter<Media, SearchImagesViewHolder>(object : DiffUtil.ItemCallback<Media>() {
override fun areItemsTheSame(oldItem: Media, newItem: Media) =
oldItem.pageId == newItem.pageId
override fun areContentsTheSame(oldItem: Media, newItem: Media) =
oldItem.pageId == newItem.pageId
}) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
SearchImagesViewHolder(
parent.inflate(R.layout.layout_category_images),
onImageClicked
)
override fun onBindViewHolder(holder: SearchImagesViewHolder, position: Int) {
holder.bind(getItem(position)!! to position)
}
}
class SearchImagesViewHolder(containerView: View, val onImageClicked: (Int) -> Unit) :
BaseViewHolder<Pair<Media, Int>>(containerView) {
override fun bind(item: Pair<Media, Int>) {
val media = item.first
categoryImageView.setOnClickListener { onImageClicked(item.second) }
categoryImageTitle.text = media.thumbnailTitle
categoryImageView.setImageURI(media.thumbUrl)
if (media.creator?.isNotEmpty() == true) {
categoryImageAuthor.visibility = View.VISIBLE
categoryImageAuthor.text =
containerView.context.getString(R.string.image_uploaded_by, media.creator)
} else {
categoryImageAuthor.visibility = View.GONE
}
}
}

View file

@ -0,0 +1,57 @@
package fr.free.nrw.commons.explore.media
import android.os.Bundle
import android.view.View
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.R
import fr.free.nrw.commons.explore.BaseSearchFragment
import fr.free.nrw.commons.explore.SearchActivity
import javax.inject.Inject
/**
* Displays the image search screen.
*/
class SearchMediaFragment : BaseSearchFragment<Media>(), SearchMediaFragmentContract.View {
@Inject
lateinit var presenter: SearchMediaFragmentContract.Presenter
override val emptyTemplateTextId: Int = R.string.depictions_not_found
override val errorTextId: Int = R.string.error_loading_images
override val injectedPresenter: SearchMediaFragmentContract.Presenter
get() = presenter
override val pagedListAdapter by lazy {
SearchImagesAdapter {
(context as SearchActivity?)!!.onSearchImageClicked(it)
}
}
private val simpleDataObserver = SimpleDataObserver { notifyViewPager() }
fun requestMoreImages() {
// This functionality is replaced by a dataSetObserver and by using loadAround
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
pagedListAdapter.registerAdapterDataObserver(simpleDataObserver)
}
override fun onDestroyView() {
super.onDestroyView()
pagedListAdapter.unregisterAdapterDataObserver(simpleDataObserver)
}
private fun notifyViewPager() {
(activity as SearchActivity).viewPagerNotifyDataSetChanged()
}
fun getImageAtPosition(position: Int): Media? =
pagedListAdapter.currentList?.get(position)?.takeIf { it.filename != null }
.also { pagedListAdapter.currentList?.loadAround(position) }
fun getTotalImagesCount(): Int = pagedListAdapter.itemCount
}

View file

@ -0,0 +1,10 @@
package fr.free.nrw.commons.explore.media
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.explore.SearchFragmentContract
interface SearchMediaFragmentContract {
interface View : SearchFragmentContract.View<Media>
interface Presenter : SearchFragmentContract.Presenter<Media>
}

View file

@ -0,0 +1,14 @@
package fr.free.nrw.commons.explore.media
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.di.CommonsApplicationModule
import fr.free.nrw.commons.explore.BaseSearchPresenter
import io.reactivex.Scheduler
import javax.inject.Inject
import javax.inject.Named
class SearchMediaFragmentPresenter @Inject constructor(
@Named(CommonsApplicationModule.MAIN_THREAD) mainThreadScheduler: Scheduler,
dataSourceFactory: PageableMediaDataSource
) : BaseSearchPresenter<Media>(mainThreadScheduler, dataSourceFactory),
SearchMediaFragmentContract.Presenter

View file

@ -0,0 +1,40 @@
package fr.free.nrw.commons.explore.media
import androidx.recyclerview.widget.RecyclerView
class SimpleDataObserver(private val onAnyChange: () -> Unit) : RecyclerView.AdapterDataObserver() {
override fun onChanged() {
super.onChanged()
onAnyChange.invoke()
}
override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
super.onItemRangeRemoved(positionStart, itemCount)
onAnyChange.invoke()
}
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
super.onItemRangeMoved(fromPosition, toPosition, itemCount)
onAnyChange.invoke()
}
override fun onStateRestorationPolicyChanged() {
super.onStateRestorationPolicyChanged()
onAnyChange.invoke()
}
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
super.onItemRangeInserted(positionStart, itemCount)
onAnyChange.invoke()
}
override fun onItemRangeChanged(positionStart: Int, itemCount: Int) {
super.onItemRangeChanged(positionStart, itemCount)
onAnyChange.invoke()
}
override fun onItemRangeChanged(positionStart: Int, itemCount: Int, payload: Any?) {
super.onItemRangeChanged(positionStart, itemCount, payload)
onAnyChange.invoke()
}
}

View file

@ -124,14 +124,12 @@ public class MediaClient {
* It uses the generator query API to get the images searched using a query, 10 at a time.
*
* @param keyword the search keyword
* @param limit
* @param offset
* @return
*/
public Single<List<Media>> getMediaListFromSearch(String keyword) {
return responseToMediaList(
continuationStore.containsKey("search_" + keyword) && (continuationStore.get("search_" + keyword) != null) ?
mediaInterface.getMediaListFromSearch(keyword, 10, continuationStore.get("search_" + keyword)) : //if true
mediaInterface.getMediaListFromSearch(keyword, 10, Collections.emptyMap()), //if false
"search_" + keyword);
public Single<MwQueryResponse> getMediaListFromSearch(String keyword, int limit, int offset) {
return mediaInterface.getMediaListFromSearch(keyword, limit, offset);
}
@ -270,9 +268,10 @@ public class MediaClient {
}
}
throw new RuntimeException("failed getEntities");
})
.singleOrError();
});
}
public Single<Entities> getEntities(String entityId) {
return mediaDetailInterface.getEntity(entityId);
}
}

View file

@ -1,6 +1,7 @@
package fr.free.nrw.commons.media;
import io.reactivex.Observable;
import io.reactivex.Single;
import org.wikipedia.wikidata.Entities;
import retrofit2.http.GET;
import retrofit2.http.Query;
@ -24,7 +25,7 @@ public interface MediaDetailInterface {
*
*/
@GET("/w/api.php?format=json&action=wbgetentities&props=labels&languagefallback=1")
Observable<Entities> getEntity(@Query("ids") String entityId);
Single<Entities> getEntity(@Query("ids") String entityId);
/**
* Fetches caption using wikibaseIdentifier

View file

@ -2,6 +2,7 @@ package fr.free.nrw.commons.media;
import fr.free.nrw.commons.depictions.models.DepictionResponse;
import io.reactivex.Observable;
import io.reactivex.Single;
import java.util.Map;
import org.wikipedia.dataclient.mwapi.MwQueryResponse;
import retrofit2.http.GET;
@ -65,13 +66,13 @@ public interface MediaInterface {
*
* @param keyword the searched keyword
* @param itemLimit how many images are returned
* @param continuation the continuation string from the previous query
* @param offset the offset in the result set
* @return
*/
@GET("w/api.php?action=query&format=json&formatversion=2" + //Basic parameters
"&generator=search&gsrwhat=text&gsrnamespace=6" + //Search parameters
MEDIA_PARAMS)
Observable<MwQueryResponse> getMediaListFromSearch(@Query("gsrsearch") String keyword, @Query("gsrlimit") int itemLimit, @QueryMap Map<String, String> continuation);
Single<MwQueryResponse> getMediaListFromSearch(@Query("gsrsearch") String keyword, @Query("gsrlimit") int itemLimit, @Query("gsroffset") int offset);
/**
* Fetches Media object from the imageInfo API

View file

@ -34,7 +34,7 @@ class BaseSearchPresenterTest {
private var searchResults: PublishProcessor<LiveData<PagedList<String>>> =
PublishProcessor.create()
private var noItemLoadedEvent: PublishProcessor<Unit> = PublishProcessor.create()
private var noItemLoadedQueries: PublishProcessor<String> = PublishProcessor.create()
@Before
@Throws(Exception::class)
@ -42,8 +42,8 @@ class BaseSearchPresenterTest {
MockitoAnnotations.initMocks(this)
whenever(pageableDataSource.searchResults).thenReturn(searchResults)
whenever(pageableDataSource.loadingStates).thenReturn(loadingStates)
whenever(pageableDataSource.noItemsLoadedEvent)
.thenReturn(noItemLoadedEvent)
whenever(pageableDataSource.noItemsLoadedQueries)
.thenReturn(noItemLoadedQueries)
testScheduler = TestScheduler()
baseSearchPresenter =
object : BaseSearchPresenter<String>(testScheduler, pageableDataSource) {}
@ -60,6 +60,7 @@ class BaseSearchPresenterTest {
@Test
fun `Loading offers a loading list item`() {
onLoadingState(LoadingState.Loading)
verify(view).hideEmptyText()
baseSearchPresenter.listFooterData.test().assertValue(listOf(FooterItem.LoadingItem))
}
@ -74,6 +75,7 @@ class BaseSearchPresenterTest {
@Test
fun `InitialLoad shows initial loader`() {
onLoadingState(LoadingState.InitialLoad)
verify(view).hideEmptyText()
verify(view).showInitialLoadInProgress()
}
@ -81,16 +83,6 @@ class BaseSearchPresenterTest {
fun `Error offers a refresh list item, hides initial loader and shows error with a set text`() {
baseSearchPresenter.onQueryUpdated("test")
onLoadingState(LoadingState.Error)
verify(view).setEmptyViewText("test")
verify(view).showSnackbar()
verify(view).hideInitialLoadProgress()
baseSearchPresenter.listFooterData.test().assertValue(listOf(FooterItem.RefreshItem))
}
@Test
fun `Error offers a refresh list item, hides initial loader and shows error with a unset text`() {
onLoadingState(LoadingState.Error)
verify(view, never()).setEmptyViewText(any())
verify(view).showSnackbar()
verify(view).hideInitialLoadProgress()
baseSearchPresenter.listFooterData.test().assertValue(listOf(FooterItem.RefreshItem))
@ -98,9 +90,8 @@ class BaseSearchPresenterTest {
@Test
fun `no Items event sets empty view text`() {
baseSearchPresenter.onQueryUpdated("test")
noItemLoadedEvent.offer(Unit)
verify(view).setEmptyViewText("test")
noItemLoadedQueries.offer("test")
verify(view).showEmptyText("test")
}
@Test

View file

@ -39,9 +39,9 @@ class PageableDataSourceTest {
fun `onQueryUpdated invokes livedatconverter with no items emitter`() {
val (zeroItemsFuncCaptor, _) = expectNewLiveData()
pageableDataSource.onQueryUpdated("test")
pageableDataSource.noItemsLoadedEvent.test()
pageableDataSource.noItemsLoadedQueries.test()
.also { zeroItemsFuncCaptor.firstValue.invoke() }
.assertValue(Unit)
.assertValue("test")
}
/*

View file

@ -0,0 +1,71 @@
package fr.free.nrw.commons.explore.media
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.verify
import com.nhaarman.mockitokotlin2.whenever
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.depictions.Media.DepictedImagesFragment.PAGE_ID_PREFIX
import fr.free.nrw.commons.media.MediaClient
import io.reactivex.Single
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.`is`
import org.junit.Before
import org.junit.Test
import org.mockito.Mock
import org.mockito.MockitoAnnotations
import org.wikipedia.dataclient.mwapi.MwQueryPage
import org.wikipedia.dataclient.mwapi.MwQueryResponse
import org.wikipedia.dataclient.mwapi.MwQueryResult
import org.wikipedia.wikidata.Entities
class PageableMediaDataSourceTest {
@Mock
lateinit var mediaConverter: MediaConverter
@Mock
lateinit var mediaClient: MediaClient
@Before
fun setUp() {
MockitoAnnotations.initMocks(this)
}
@Test
fun `loadFunction invokes mediaClient and has Label`() {
val (media, entity: Entities.Entity) = expectMediaAndEntity()
val label: Entities.Label = mock()
whenever(entity.labels()).thenReturn(mapOf(" " to label))
whenever(label.value()).thenReturn("label")
val pageableMediaDataSource = PageableMediaDataSource(mock(), mediaConverter, mediaClient)
pageableMediaDataSource.onQueryUpdated("test")
assertThat(pageableMediaDataSource.loadFunction(0,1), `is`(listOf(media)))
verify(media).caption = "label"
}
@Test
fun `loadFunction invokes mediaClient and does not have Label`() {
val (media, entity: Entities.Entity) = expectMediaAndEntity()
whenever(entity.labels()).thenReturn(mapOf())
val pageableMediaDataSource = PageableMediaDataSource(mock(), mediaConverter, mediaClient)
pageableMediaDataSource.onQueryUpdated("test")
assertThat(pageableMediaDataSource.loadFunction(0,1), `is`(listOf(media)))
verify(media).caption = MediaClient.NO_CAPTION
}
private fun expectMediaAndEntity(): Pair<Media, Entities.Entity> {
val queryResponse: MwQueryResponse = mock()
whenever(mediaClient.getMediaListFromSearch("test", 0, 1))
.thenReturn(Single.just(queryResponse))
val queryResult: MwQueryResult = mock()
whenever(queryResponse.query()).thenReturn(queryResult)
val queryPage: MwQueryPage = mock()
whenever(queryResult.pages()).thenReturn(listOf(queryPage))
val media = mock<Media>()
whenever(mediaConverter.convert(queryPage)).thenReturn(media)
whenever(media.pageId).thenReturn("1")
val entities: Entities = mock()
whenever(mediaClient.getEntities("${PAGE_ID_PREFIX}1")).thenReturn(Single.just(entities))
val entity: Entities.Entity = mock()
whenever(entities.entities()).thenReturn(mapOf("" to entity))
return Pair(media, entity)
}
}