#3756 Convert SearchDepictionsFragment to use Pagination - convert SearchDepictionsFragment

This commit is contained in:
Sean Mac Gillicuddy 2020-05-19 14:38:55 +01:00
parent 9f2dcb8c5e
commit 4866fee7f2
24 changed files with 566 additions and 481 deletions

View file

@ -45,6 +45,10 @@ dependencies {
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"
implementation "androidx.paging:paging-runtime-ktx:$PAGING_VERSION"
testImplementation "androidx.paging:paging-common-ktx:$PAGING_VERSION"
implementation "androidx.paging:paging-rxjava2-ktx:$PAGING_VERSION"
implementation "androidx.recyclerview:recyclerview:1.2.0-alpha02"
// Logging
implementation 'ch.acra:acra-dialog:5.3.0'

View file

@ -1,5 +1,7 @@
package fr.free.nrw.commons;
import androidx.annotation.NonNull;
/**
* Base presenter, enforcing contracts to atach and detach view
*/
@ -7,7 +9,7 @@ public interface BasePresenter<T> {
/**
* Until a view is attached, it is open to listen events from the presenter
*/
void onAttachView(T view);
void onAttachView(@NonNull T view);
/**
* Detaching a view makes sure that the view no more receives events from the presenter

View file

@ -0,0 +1,11 @@
package fr.free.nrw.commons.depictions.subClass
import fr.free.nrw.commons.explore.depictions.depictionDelegate
import fr.free.nrw.commons.upload.categories.BaseDelegateAdapter
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
class SubDepictionAdapter(clickListener: (DepictedItem) -> Unit) :
BaseDelegateAdapter<DepictedItem>(
depictionDelegate(clickListener),
areItemsTheSame = { oldItem, newItem -> oldItem.id == newItem.id }
)

View file

@ -1,10 +1,9 @@
package fr.free.nrw.commons.depictions.subClass;
import java.io.IOException;
import java.util.List;
import fr.free.nrw.commons.BasePresenter;
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem;
import java.io.IOException;
import java.util.List;
/**
* The contract with which SubDepictionListFragment and its presenter would talk to each other
@ -28,5 +27,6 @@ public interface SubDepictionListContract {
void initSubDepictionList(String qid, Boolean isParentClass) throws IOException;
String getQuery();
}
}

View file

@ -20,7 +20,6 @@ import butterknife.ButterKnife;
import dagger.android.support.DaggerFragment;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.depictions.WikidataItemDetailsActivity;
import fr.free.nrw.commons.explore.depictions.DepictionAdapter;
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem;
import fr.free.nrw.commons.utils.NetworkUtils;
import fr.free.nrw.commons.utils.ViewUtil;
@ -47,7 +46,7 @@ public class SubDepictionListFragment extends DaggerFragment implements SubDepic
* Keeps a record of whether current instance of the fragment if of SubClass or ParentClass
*/
private boolean isParentClass = false;
private DepictionAdapter depictionsAdapter;
private SubDepictionAdapter depictionsAdapter;
RecyclerView.LayoutManager layoutManager;
/**
* Stores entityId for the depiction
@ -100,7 +99,7 @@ public class SubDepictionListFragment extends DaggerFragment implements SubDepic
}
initViews();
depictionsRecyclerView.setLayoutManager(layoutManager);
depictionsAdapter = new DepictionAdapter(depictedItem -> {
depictionsAdapter = new SubDepictionAdapter(depictedItem -> {
// Open SubDepiction Details page
getActivity().finish();
WikidataItemDetailsActivity.startYourself(getContext(), depictedItem);

View file

@ -117,7 +117,7 @@ public class SearchActivity extends NavigationBaseActivity
searchHistoryContainer.setVisibility(View.GONE);
if (FragmentUtils.isFragmentUIActive(searchDepictionsFragment)) {
searchDepictionsFragment.updateDepictionList(query.toString());
searchDepictionsFragment.onQueryUpdated(query.toString());
}
if (FragmentUtils.isFragmentUIActive(searchImageFragment)) {

View file

@ -2,6 +2,10 @@ 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;
@ -16,4 +20,10 @@ public abstract class SearchModule {
SearchDepictionsFragmentPresenter
presenter
);
@Provides
static public SearchDepictionsDataSourceFactoryFactory providesSearchDepictionsFactoryFactory(
DepictsClient depictsClient){
return (query, loadingStates) -> new SearchDepictionsDataSourceFactory(depictsClient, query, loadingStates);
}
}

View file

@ -1,12 +1,51 @@
package fr.free.nrw.commons.explore.depictions
import fr.free.nrw.commons.upload.categories.BaseDelegateAdapter
import android.view.View
import android.view.ViewGroup
import androidx.paging.PagedListAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import fr.free.nrw.commons.R
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.item_depictions.*
class DepictionAdapter(val onDepictionClicked: (DepictedItem) -> Unit) :
PagedListAdapter<DepictedItem, DepictedItemViewHolder>(
object : DiffUtil.ItemCallback<DepictedItem>() {
override fun areItemsTheSame(oldItem: DepictedItem, newItem: DepictedItem) =
oldItem.id == newItem.id
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))
}
override fun onBindViewHolder(holder: DepictedItemViewHolder, position: Int) {
holder.bind(getItem(position)!!, onDepictionClicked)
}
}
class DepictedItemViewHolder(override val containerView: View) :
RecyclerView.ViewHolder(containerView), LayoutContainer {
fun bind(item: DepictedItem, onDepictionClicked: (DepictedItem) -> Unit) {
containerView.setOnClickListener { onDepictionClicked(item) }
depicts_label.text = item.name
description.text = item.description
if (item.imageUrl?.isNotBlank() == true) {
depicts_image.setImageURI(item.imageUrl)
} else {
depicts_image.setActualImageResource(R.drawable.ic_wikidata_logo_24dp)
}
}
}
class DepictionAdapter(clickListener: (DepictedItem) -> Unit) : BaseDelegateAdapter<DepictedItem>(
depictionDelegate(clickListener),
areItemsTheSame = { oldItem, newItem -> oldItem.id == newItem.id }
)

View file

@ -0,0 +1,54 @@
package fr.free.nrw.commons.explore.depictions
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<FooterItem, FooterViewHolder>(object :
DiffUtil.ItemCallback<FooterItem>() {
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)

View file

@ -0,0 +1,63 @@
package fr.free.nrw.commons.explore.depictions
import androidx.paging.PositionalDataSource
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
import io.reactivex.Completable
import io.reactivex.processors.PublishProcessor
import io.reactivex.schedulers.Schedulers
import timber.log.Timber
class SearchDepictionsDataSource constructor(
private val depictsClient: DepictsClient,
private val loadingStates: PublishProcessor<LoadingState>,
val query: String
) : PositionalDataSource<DepictedItem>() {
var lastExecutedRequest: (() -> Boolean)? = null
override fun loadInitial(
params: LoadInitialParams,
callback: LoadInitialCallback<DepictedItem>
) {
storeAndExecute {
loadingStates.offer(LoadingState.InitialLoad)
performWithTryCatch {
callback.onResult(
getItems(query, params.requestedLoadSize, params.requestedStartPosition),
params.requestedStartPosition
)
}
}
}
override fun loadRange(params: LoadRangeParams, callback: LoadRangeCallback<DepictedItem>) {
storeAndExecute {
loadingStates.offer(LoadingState.Loading)
performWithTryCatch {
callback.onResult(getItems(query, params.loadSize, params.startPosition))
}
}
}
fun retryFailedRequest() {
Completable.fromAction { lastExecutedRequest?.invoke() }
.subscribeOn(Schedulers.io())
.subscribe()
}
private fun getItems(query: String, limit: Int, offset: Int) =
depictsClient.searchForDepictions(query, limit, offset).blockingGet()
private fun storeAndExecute(function: () -> Boolean) {
function.also { lastExecutedRequest = it }.invoke()
}
private fun performWithTryCatch(function: () -> Unit) = try {
function.invoke()
loadingStates.offer(LoadingState.Complete)
} catch (e: Exception) {
Timber.e(e)
loadingStates.offer(LoadingState.Error)
}
}

View file

@ -1,200 +0,0 @@
package fr.free.nrw.commons.explore.depictions;
import static android.view.View.GONE;
import static android.view.View.VISIBLE;
import static fr.free.nrw.commons.explore.depictions.DepictionAdapterDelegatesKt.depictionDelegate;
import android.content.Context;
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.annotation.NonNull;
import androidx.recyclerview.widget.DiffUtil.ItemCallback;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import butterknife.BindView;
import butterknife.ButterKnife;
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter;
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.upload.structure.depictions.DepictedItem;
import fr.free.nrw.commons.utils.NetworkUtils;
import fr.free.nrw.commons.utils.ViewUtil;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import javax.inject.Inject;
import kotlin.Unit;
/**
* Display depictions in search fragment
*/
public class SearchDepictionsFragment extends CommonsDaggerSupportFragment implements SearchDepictionsFragmentContract.View {
@BindView(R.id.imagesListBox)
RecyclerView depictionsRecyclerView;
@BindView(R.id.imageSearchInProgress)
ProgressBar progressBar;
@BindView(R.id.imagesNotFound)
TextView depictionNotFound;
@BindView(R.id.bottomProgressBar)
ProgressBar bottomProgressBar;
private RecyclerView.LayoutManager layoutManager;
private boolean isLoading = true;
private final int PAGE_SIZE = 25;
@Inject
SearchDepictionsFragmentPresenter presenter;
private DepictionAdapter depictionsAdapter;
private boolean isLastPage;
@Override
public View onCreateView(final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) {
final View rootView = inflater.inflate(R.layout.fragment_browse_image, container, false);
ButterKnife.bind(this, rootView);
if (getActivity().getResources().getConfiguration().orientation
== Configuration.ORIENTATION_PORTRAIT) {
layoutManager = new LinearLayoutManager(getContext());
} else {
layoutManager = new GridLayoutManager(getContext(), 2);
}
depictionsRecyclerView.setLayoutManager(layoutManager);
depictionsAdapter = new DepictionAdapter(
depictedItem -> {
WikidataItemDetailsActivity.startYourself(getContext(), depictedItem);
presenter.saveQuery();
return Unit.INSTANCE;
});
depictionsRecyclerView.setAdapter(depictionsAdapter);
depictionsRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(final RecyclerView recyclerView, final int newState) {
super.onScrollStateChanged(recyclerView, newState);
}
@Override
public void onScrolled(@NonNull final RecyclerView recyclerView, final int dx, final int dy) {
super.onScrolled(recyclerView, dx, dy);
final int visibleItemCount = layoutManager.getChildCount();
final int totalItemCount = layoutManager.getItemCount();
int firstVisibleItemPosition=0;
if(layoutManager instanceof GridLayoutManager){
firstVisibleItemPosition=((GridLayoutManager) layoutManager).findFirstVisibleItemPosition();
} else {
firstVisibleItemPosition=((LinearLayoutManager)layoutManager).findFirstVisibleItemPosition();
}
/**
* If the user isn't currently loading items and the last page hasnt been reached,
* then it checks against the current position in view to decide whether or not to load more items.
*/
if (!isLoading && !isLastPage) {
if ((visibleItemCount + firstVisibleItemPosition) >= totalItemCount
&& firstVisibleItemPosition >= 0
&& totalItemCount >= PAGE_SIZE) {
loadMoreItems(false);
}
}
}
});
return rootView;
}
/**
* Fetch PAGE_SIZE number of items
*/
private void loadMoreItems(final boolean reInitialise) {
presenter.updateDepictionList(presenter.getQuery(),PAGE_SIZE, reInitialise);
}
@Override
public void onAttach(final Context context) {
super.onAttach(context);
presenter.onAttachView(this);
}
/**
* Called when user selects "Items" from Search Activity
* to load the list of depictions from API
*
* @param query string searched in the Explore Activity
*/
public void updateDepictionList(final String query) {
presenter.initializeQuery(query);
if (!NetworkUtils.isInternetConnectionEstablished(getContext())) {
handleNoInternet();
return;
}
loadMoreItems(true);
}
/**
* Handles the UI updates for a error scenario
*/
@Override
public void initErrorView() {
isLoading = false;
progressBar.setVisibility(GONE);
bottomProgressBar.setVisibility(GONE);
depictionNotFound.setVisibility(VISIBLE);
final String no_depiction = getString(R.string.depictions_not_found);
depictionNotFound.setText(String.format(Locale.getDefault(), no_depiction, presenter.getQuery()));
}
/**
* Handles the UI updates for no internet scenario
*/
@Override
public void handleNoInternet() {
progressBar.setVisibility(GONE);
ViewUtil.showShortSnackbar(depictionsRecyclerView, R.string.no_internet);
}
/**
* If a non empty list is successfully returned from the api then modify the view
* like hiding empty labels, hiding progressbar and notifying the apdapter that list of items has been fetched from the API
*/
@Override
public void onSuccess(final List<DepictedItem> mediaList) {
isLoading = false;
progressBar.setVisibility(GONE);
depictionNotFound.setVisibility(GONE);
bottomProgressBar.setVisibility(GONE);
depictionsAdapter.addAll(mediaList);
}
@Override
public void loadingDepictions(final boolean isLoading) {
depictionNotFound.setVisibility(GONE);
bottomProgressBar.setVisibility(VISIBLE);
progressBar.setVisibility(GONE);
this.isLoading = isLoading;
}
@Override
public void clearAdapter() {
depictionsAdapter.clear();
}
@Override
public void showSnackbar() {
ViewUtil.showShortSnackbar(depictionsRecyclerView, R.string.error_loading_depictions);
}
/**
* Inform the view that there are no more items to be loaded for this search query
* or reset the isLastPage for the current query
* @param isLastPage
*/
@Override
public void setIsLastPage(final boolean isLastPage) {
this.isLastPage=isLastPage;
progressBar.setVisibility(GONE);
}
}

View file

@ -0,0 +1,99 @@
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.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(),
SearchDepictionsFragmentContract.View {
@Inject
lateinit var presenter: SearchDepictionsFragmentContract.UserActionListener
private val depictionsAdapter by lazy {
DepictionAdapter { WikidataItemDetailsActivity.startYourself(context, it) }
}
private val loadingAdapter by lazy { FooterAdapter { presenter.retryFailedRequest() } }
private val mergeAdapter by lazy { MergeAdapter(depictionsAdapter, loadingAdapter) }
var searchResults: LiveData<PagedList<DepictedItem>>? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
) = inflater.inflate(R.layout.fragment_search_depictions, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
depictionsSearchResultsList.apply {
layoutManager = GridLayoutManager(context, if (isPortrait) 1 else 2)
adapter = mergeAdapter
}
presenter.listFooterData.observe(viewLifecycleOwner, Observer(loadingAdapter::submitList))
}
override fun onAttach(context: Context) {
super.onAttach(context)
presenter.onAttachView(this)
}
override fun onDetach() {
super.onDetach()
presenter.onDetachView()
}
override fun observeSearchResults(searchResults: LiveData<PagedList<DepictedItem>>) {
this.searchResults?.removeObservers(viewLifecycleOwner)
this.searchResults = searchResults
searchResults.observe(viewLifecycleOwner, Observer {
depictionsAdapter.submitList(it)
depictionNotFound.visibility = if (it.loadedCount == 0) VISIBLE else GONE
})
}
override fun setEmptyViewText(query: String) {
depictionNotFound.text = getString(R.string.depictions_not_found, query)
}
override fun hideInitialLoadProgress() {
depictionSearchInitialLoadProgress.visibility = GONE
}
override fun showInitialLoadInProgress() {
depictionSearchInitialLoadProgress.visibility = VISIBLE
}
override fun showSnackbar() {
ViewUtil.showShortSnackbar(depictionsSearchResultsList, R.string.error_loading_depictions)
}
fun onQueryUpdated(query: String) {
presenter.onQueryUpdated(query)
}
}
private val Fragment.isPortrait get() = orientation == Configuration.ORIENTATION_PORTRAIT
private val Fragment.orientation get() = activity!!.resources.configuration.orientation

View file

@ -1,79 +0,0 @@
package fr.free.nrw.commons.explore.depictions;
import fr.free.nrw.commons.BasePresenter;
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem;
import java.util.List;
/**
* The contract with with SearchDepictionsFragment and its presenter would talk to each other
*/
public interface SearchDepictionsFragmentContract {
interface View {
/**
* Handles the UI updates for a error scenario
*/
void initErrorView();
/**
* Handles the UI updates for no internet scenario
*/
void handleNoInternet();
/**
* If a non empty list is successfully returned from the api then modify the view
* like hiding empty labels, hiding progressbar and notifying the apdapter that list of items has been fetched from the API
*/
void onSuccess(List<DepictedItem> mediaList);
/**
* load depictions
*/
void loadingDepictions(boolean isLoading);
/**
* clear adapter
*/
void clearAdapter();
/**
* show snackbar
*/
void showSnackbar();
/**
* Inform the view that there are no more items to be loaded for this search query
* or reset the isLastPage for the current query
* @param isLastPage
*/
void setIsLastPage(boolean isLastPage);
}
interface UserActionListener extends BasePresenter<View> {
/**
* Called when user selects "Items" from Search Activity
* to load the list of depictions from API
*
* @param query string searched in the Explore Activity
* @param reInitialise
*/
void updateDepictionList(String query, int pageSize, boolean reInitialise);
/**
* This method saves Search Query in the Recent Searches Database.
*/
void saveQuery();
/**
* Whenever a new query is initiated from the search activity clear the previous adapter
* and add new value of the query
*/
void initializeQuery(String query);
/**
* @return query
*/
String getQuery();
}
}

View file

@ -0,0 +1,25 @@
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.upload.structure.depictions.DepictedItem
/**
* The contract with with SearchDepictionsFragment and its presenter would talk to each other
*/
interface SearchDepictionsFragmentContract {
interface View {
fun showSnackbar()
fun observeSearchResults(searchResults: LiveData<PagedList<DepictedItem>>)
fun setEmptyViewText(query: String)
fun showInitialLoadInProgress()
fun hideInitialLoadProgress()
}
interface UserActionListener : BasePresenter<View?> {
val listFooterData: LiveData<List<FooterItem>>
fun onQueryUpdated(query: String)
fun retryFailedRequest()
}
}

View file

@ -1,159 +0,0 @@
package fr.free.nrw.commons.explore.depictions;
import static fr.free.nrw.commons.di.CommonsApplicationModule.IO_THREAD;
import static fr.free.nrw.commons.di.CommonsApplicationModule.MAIN_THREAD;
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.upload.structure.depictions.DepictedItem;
import io.reactivex.Scheduler;
import io.reactivex.disposables.CompositeDisposable;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Named;
import timber.log.Timber;
/**
* The presenter class for SearchDepictionsFragment
*/
public class SearchDepictionsFragmentPresenter extends CommonsDaggerSupportFragment implements SearchDepictionsFragmentContract.UserActionListener {
/**
* This creates a dynamic proxy instance of the class,
* proxy is to control access to the target object
* here our target object is the view.
* Thus we when onDettach method of fragment is called we replace the binding of view to our object with the proxy instance
*/
private static final SearchDepictionsFragmentContract.View DUMMY = (SearchDepictionsFragmentContract.View) Proxy
.newProxyInstance(
SearchDepictionsFragmentContract.View.class.getClassLoader(),
new Class[]{SearchDepictionsFragmentContract.View.class},
(proxy, method, methodArgs) -> null);
protected CompositeDisposable compositeDisposable = new CompositeDisposable();
private final Scheduler ioScheduler;
private final Scheduler mainThreadScheduler;
boolean isLoadingDepictions;
String query;
RecentSearchesDao recentSearchesDao;
DepictsClient depictsClient;
JsonKvStore basicKvStore;
private SearchDepictionsFragmentContract.View view = DUMMY;
private List<DepictedItem> queryList = new ArrayList<>();
int offset=0;
int size = 0;
@Inject
public SearchDepictionsFragmentPresenter(@Named("default_preferences") JsonKvStore basicKvStore,
RecentSearchesDao recentSearchesDao,
DepictsClient depictsClient,
@Named(IO_THREAD) Scheduler ioScheduler,
@Named(MAIN_THREAD) Scheduler mainThreadScheduler) {
this.basicKvStore = basicKvStore;
this.recentSearchesDao = recentSearchesDao;
this.depictsClient = depictsClient;
this.ioScheduler = ioScheduler;
this.mainThreadScheduler = mainThreadScheduler;
}
@Override
public void onAttachView(SearchDepictionsFragmentContract.View view) {
this.view = view;
}
@Override
public void onDetachView() {
this.view = DUMMY;
}
/**
* Called when user selects "Items" from Search Activity
* to load the list of depictions from API
*
* @param query string searched in the Explore Activity
* @param reInitialise
*/
@Override
public void updateDepictionList(String query, int pageSize, boolean reInitialise) {
this.query = query;
view.loadingDepictions(true);
if (reInitialise) {
size = 0;
}
saveQuery();
compositeDisposable.add(depictsClient.searchForDepictions(query, 25, offset)
.subscribeOn(ioScheduler)
.observeOn(mainThreadScheduler)
.doOnSubscribe(disposable -> saveQuery())
.subscribe(this::handleSuccess, this::handleError));
}
/**
* Logs and handles API error scenario
*/
private void handleError(Throwable throwable) {
Timber.e(throwable, "Error occurred while loading queried depictions");
view.initErrorView();
view.showSnackbar();
}
/**
* This method saves Search Query in the Recent Searches Database.
*/
@Override
public void saveQuery() {
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);
}
/**
* Whenever a new query is initiated from the search activity clear the previous adapter
* and add new value of the query
*/
@Override
public void initializeQuery(String query) {
this.query = query;
this.queryList.clear();
offset = 0;//Reset the offset on query change
compositeDisposable.clear();
view.setIsLastPage(false);
view.clearAdapter();
}
@Override
public String getQuery() {
return query;
}
/**
* Handles the success scenario
* it initializes the recycler view by adding items to the adapter
*/
public void handleSuccess(List<DepictedItem> mediaList) {
if (mediaList == null || mediaList.isEmpty()) {
if(queryList.isEmpty()){
view.initErrorView();
}else{
view.setIsLastPage(true);
}
} else {
this.queryList.addAll(mediaList);
view.onSuccess(mediaList);
offset=queryList.size();
}
}
}

View file

@ -0,0 +1,69 @@
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 io.reactivex.Scheduler
import io.reactivex.disposables.CompositeDisposable
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Named
/**
* The presenter class for SearchDepictionsFragment
*/
class SearchDepictionsFragmentPresenter @Inject constructor(
@param:Named(CommonsApplicationModule.MAIN_THREAD) private val mainThreadScheduler: Scheduler,
private val searchableDataSourceFactory: SearchableDepictionsDataSourceFactory
) : SearchDepictionsFragmentContract.UserActionListener {
private val compositeDisposable = CompositeDisposable()
private var view = DUMMY
private var currentQuery: String? = null
override val listFooterData = MutableLiveData<List<FooterItem>>().apply { value = emptyList() }
override fun onAttachView(view: SearchDepictionsFragmentContract.View) {
this.view = view
compositeDisposable.addAll(
searchableDataSourceFactory.searchResults.subscribe(view::observeSearchResults),
searchableDataSourceFactory.loadingStates
.observeOn(mainThreadScheduler)
.subscribe(::onLoadingState, Timber::e),
searchableDataSourceFactory.noItemsLoadedEvent.subscribe {
currentQuery?.let(view::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 -> {
currentQuery?.let(view::setEmptyViewText)
view.showSnackbar()
view.hideInitialLoadProgress()
listFooterData.postValue(listOf(FooterItem.RefreshItem))
}
}
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()
}
}

View file

@ -0,0 +1,75 @@
package fr.free.nrw.commons.explore.depictions
import androidx.lifecycle.LiveData
import androidx.paging.Config
import androidx.paging.DataSource
import androidx.paging.PagedList
import androidx.paging.toLiveData
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
import 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
class SearchableDepictionsDataSourceFactory @Inject constructor(val searchDepictionsDataSourceFactoryFactory: SearchDepictionsDataSourceFactoryFactory) {
private val _loadingStates = PublishProcessor.create<LoadingState>()
val loadingStates: Flowable<LoadingState> = _loadingStates
private val _searchResults = PublishProcessor.create<LiveData<PagedList<DepictedItem>>>()
val searchResults: Flowable<LiveData<PagedList<DepictedItem>>> = _searchResults
private val _noItemsLoadedEvent = PublishProcessor.create<Unit>()
val noItemsLoadedEvent: Flowable<Unit> = _noItemsLoadedEvent
var currentFactory: SearchDepictionsDataSourceFactory? = null
fun onQueryUpdated(query: String) {
_searchResults.offer(
searchDepictionsDataSourceFactoryFactory.create(query, _loadingStates)
.also { currentFactory = it }
.toLiveData(
Config(
pageSize = PAGE_SIZE,
initialLoadSizeHint = INITIAL_LOAD_SIZE,
enablePlaceholders = false
),
boundaryCallback = object : PagedList.BoundaryCallback<DepictedItem>() {
override fun onZeroItemsLoaded() {
_noItemsLoadedEvent.offer(Unit)
}
}
)
)
}
fun retryFailedRequest() {
currentFactory?.retryFailedRequest()
}
}
interface SearchDepictionsDataSourceFactoryFactory {
fun create(query: String, loadingStates: PublishProcessor<LoadingState>)
: SearchDepictionsDataSourceFactory
}
class SearchDepictionsDataSourceFactory constructor(
private val depictsClient: DepictsClient,
private val query: String,
private val loadingStates: PublishProcessor<LoadingState>
) : DataSource.Factory<Int, DepictedItem>() {
var currentDataSource: SearchDepictionsDataSource? = null
override fun create() = SearchDepictionsDataSource(depictsClient, loadingStates, query).also {
currentDataSource = it
}
fun retryFailedRequest() {
currentDataSource?.retryFailedRequest()
}
}
sealed class LoadingState {
object InitialLoad : LoadingState()
object Loading : LoadingState()
object Complete : LoadingState()
object Error : LoadingState()
}

View file

@ -110,5 +110,11 @@ class DepictsPresenter @Inject constructor(
}
/**
* This creates a dynamic proxy instance of the class,
* proxy is to control access to the target object
* here our target object is the view.
* Thus we when onDettach method of fragment is called we replace the binding of view to our object with the proxy instance
*/
inline fun <reified T> proxy() = Proxy
.newProxyInstance(T::class.java.classLoader, arrayOf(T::class.java)) { _, _, _ -> null } as T

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingTop="@dimen/tiny_gap"
android:orientation="vertical">
<TextView
android:id="@+id/depictionNotFound"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:gravity="center"
android:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/depictionsSearchResultsList"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbarSize="@dimen/standard_gap"
android:scrollbars="vertical" />
<ProgressBar
android:id="@+id/depictionSearchInitialLoadProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"/>
</RelativeLayout>

View file

@ -1,37 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical" android:layout_width="match_parent"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="@dimen/tiny_gap">
<TextView
android:id="@+id/depicts_label"
android:textStyle="bold"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingTop="@dimen/tiny_gap"
android:text="Label"
android:textAppearance="?android:attr/textAppearanceMedium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/depicts_image"
app:layout_constraintTop_toTopOf="parent"
tools:text="Really really really really long long long label" />
<TextView
android:id="@+id/description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingTop="@dimen/tiny_gap"
android:text="Description"
tools:text="Really really really really long long long description description description"
app:layout_constraintStart_toEndOf="@+id/depicts_image"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/depicts_label" />
<com.facebook.drawee.view.SimpleDraweeView
android:id="@+id/depicts_image"
android:layout_width="70dp"
android:layout_height="70dp"
android:paddingRight="@dimen/tiny_gap"
app:placeholderImage="@drawable/ic_wikidata_logo_24dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
<TextView
android:id="@+id/depicts_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Label"
android:textAppearance="?android:attr/textAppearanceMedium"
android:textStyle="bold"
android:paddingTop="@dimen/tiny_gap"
app:layout_constraintLeft_toRightOf="@+id/depicts_image"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Description"
android:paddingTop="@dimen/tiny_gap"
app:layout_constraintLeft_toRightOf="@+id/depicts_image"
app:layout_constraintTop_toBottomOf="@+id/depicts_label" />
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:placeholderImage="@drawable/ic_wikidata_logo_24dp" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="@dimen/small_gap">
<Button
android:id="@+id/listItemLoadMoreButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/load_more"
android:textColor="@color/white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -628,5 +628,6 @@ Upload your first media by tapping on the add button.</string>
<string name="ask_to_turn_location_on">Turn on location?</string>
<string name="nearby_needs_location">Nearby needs location enabled to work properly</string>
<string name="use_location_from_similar_image">Did you shoot these two pictures at the same place? Do you want to use the latitude/longitude of the picture on the right?</string>
<string name="load_more">Load More</string>
</resources>

View file

@ -24,6 +24,7 @@ ROOM_VERSION=2.2.3
PREFERENCE_VERSION=1.1.0
CORE_KTX_VERSION=1.2.0
ADAPTER_DELEGATES_VERSION=4.3.0
PAGING_VERSION=2.1.2
systemProp.http.proxyPort=0
systemProp.http.proxyHost=