#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
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();
}
/**
@ -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)
}
}