diff --git a/app/build.gradle b/app/build.gradle index ab311228e..19a66ab7d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' diff --git a/app/src/main/java/fr/free/nrw/commons/BasePresenter.java b/app/src/main/java/fr/free/nrw/commons/BasePresenter.java index 2aa160520..2653b3711 100644 --- a/app/src/main/java/fr/free/nrw/commons/BasePresenter.java +++ b/app/src/main/java/fr/free/nrw/commons/BasePresenter.java @@ -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 { /** * 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 diff --git a/app/src/main/java/fr/free/nrw/commons/depictions/subClass/SubDepictionAdapter.kt b/app/src/main/java/fr/free/nrw/commons/depictions/subClass/SubDepictionAdapter.kt new file mode 100644 index 000000000..f41a38f33 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/depictions/subClass/SubDepictionAdapter.kt @@ -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( + depictionDelegate(clickListener), + areItemsTheSame = { oldItem, newItem -> oldItem.id == newItem.id } + ) diff --git a/app/src/main/java/fr/free/nrw/commons/depictions/subClass/SubDepictionListContract.java b/app/src/main/java/fr/free/nrw/commons/depictions/subClass/SubDepictionListContract.java index 0f9a8e22a..440989c1d 100644 --- a/app/src/main/java/fr/free/nrw/commons/depictions/subClass/SubDepictionListContract.java +++ b/app/src/main/java/fr/free/nrw/commons/depictions/subClass/SubDepictionListContract.java @@ -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(); + } } diff --git a/app/src/main/java/fr/free/nrw/commons/depictions/subClass/SubDepictionListFragment.java b/app/src/main/java/fr/free/nrw/commons/depictions/subClass/SubDepictionListFragment.java index db071047a..6cd22fba5 100644 --- a/app/src/main/java/fr/free/nrw/commons/depictions/subClass/SubDepictionListFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/depictions/subClass/SubDepictionListFragment.java @@ -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); diff --git a/app/src/main/java/fr/free/nrw/commons/explore/SearchActivity.java b/app/src/main/java/fr/free/nrw/commons/explore/SearchActivity.java index 548764bab..1273bb2c4 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/SearchActivity.java +++ b/app/src/main/java/fr/free/nrw/commons/explore/SearchActivity.java @@ -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)) { diff --git a/app/src/main/java/fr/free/nrw/commons/explore/SearchModule.java b/app/src/main/java/fr/free/nrw/commons/explore/SearchModule.java index f98946451..4b1fa5326 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/SearchModule.java +++ b/app/src/main/java/fr/free/nrw/commons/explore/SearchModule.java @@ -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); + } } diff --git a/app/src/main/java/fr/free/nrw/commons/explore/depictions/DepictionAdapter.kt b/app/src/main/java/fr/free/nrw/commons/explore/depictions/DepictionAdapter.kt index 39a367eff..460fe4c9d 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/depictions/DepictionAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/explore/depictions/DepictionAdapter.kt @@ -1,12 +1,47 @@ 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(clickListener: (DepictedItem) -> Unit) : BaseDelegateAdapter( - depictionDelegate(clickListener), - areItemsTheSame = { oldItem, newItem -> oldItem.id == newItem.id } -) +class DepictionAdapter(val onDepictionClicked: (DepictedItem) -> Unit) : + PagedListAdapter( + object : DiffUtil.ItemCallback() { + 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) + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/explore/depictions/FooterAdapter.kt b/app/src/main/java/fr/free/nrw/commons/explore/depictions/FooterAdapter.kt new file mode 100644 index 000000000..76d4be40a --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/depictions/FooterAdapter.kt @@ -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(object : + DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: FooterItem, newItem: FooterItem) = oldItem == newItem + + override fun areContentsTheSame(oldItem: FooterItem, newItem: FooterItem) = + oldItem == newItem + }) { + + override fun getItemViewType(position: Int): Int { + return getItem(position).ordinal + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + when (FooterItem.values()[viewType]) { + FooterItem.LoadingItem -> LoadingViewHolder(parent.inflate(R.layout.list_item_progress)) + FooterItem.RefreshItem -> RefreshViewHolder( + parent.inflate(R.layout.list_item_load_more), + onRefreshClicked + ) + } + + override fun onBindViewHolder(holder: FooterViewHolder, position: Int) {} +} + +open class FooterViewHolder(override val containerView: View) : + RecyclerView.ViewHolder(containerView), + LayoutContainer + +class LoadingViewHolder(containerView: View) : FooterViewHolder(containerView) +class RefreshViewHolder(containerView: View, onRefreshClicked: () -> Unit) : + FooterViewHolder(containerView) { + init { + listItemLoadMoreButton.setOnClickListener { onRefreshClicked() } + } +} + +enum class FooterItem { LoadingItem, RefreshItem } + +fun ViewGroup.inflate(@LayoutRes layoutId: Int, attachToRoot: Boolean = false): View = + LayoutInflater.from(context).inflate(layoutId, this, attachToRoot) diff --git a/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsDataSource.kt b/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsDataSource.kt new file mode 100644 index 000000000..413ee017e --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsDataSource.kt @@ -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 + + +data class SearchDepictionsDataSource constructor( + private val depictsClient: DepictsClient, + private val loadingStates: PublishProcessor, + private val query: String +) : PositionalDataSource() { + + private var lastExecutedRequest: (() -> Boolean)? = null + + override fun loadInitial( + params: LoadInitialParams, + callback: LoadInitialCallback + ) { + storeAndExecute { + loadingStates.offer(LoadingState.InitialLoad) + performWithTryCatch { + callback.onResult( + getItems(query, params.requestedLoadSize, params.requestedStartPosition), + params.requestedStartPosition + ) + } + } + } + + override fun loadRange(params: LoadRangeParams, callback: LoadRangeCallback) { + storeAndExecute { + loadingStates.offer(LoadingState.Loading) + performWithTryCatch { + callback.onResult(getItems(query, params.loadSize, params.startPosition)) + } + } + } + + fun retryFailedRequest() { + Completable.fromAction { lastExecutedRequest?.invoke() } + .subscribeOn(Schedulers.io()) + .subscribe() + } + + private fun getItems(query: String, limit: Int, offset: Int) = + depictsClient.searchForDepictions(query, limit, offset).blockingGet() + + private fun storeAndExecute(function: () -> Boolean) { + function.also { lastExecutedRequest = it }.invoke() + } + + private fun performWithTryCatch(function: () -> Unit) = try { + function.invoke() + loadingStates.offer(LoadingState.Complete) + } catch (e: Exception) { + Timber.e(e) + loadingStates.offer(LoadingState.Error) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragment.java b/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragment.java deleted file mode 100644 index b0d269061..000000000 --- a/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragment.java +++ /dev/null @@ -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 hasn’t 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 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); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragment.kt b/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragment.kt new file mode 100644 index 000000000..7d9ec10de --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragment.kt @@ -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>? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = inflater.inflate(R.layout.fragment_search_depictions, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + depictionsSearchResultsList.apply { + layoutManager = GridLayoutManager(context, if (isPortrait) 1 else 2) + adapter = mergeAdapter + } + presenter.listFooterData.observe(viewLifecycleOwner, Observer(loadingAdapter::submitList)) + } + + override fun onAttach(context: Context) { + super.onAttach(context) + presenter.onAttachView(this) + } + + override fun onDetach() { + super.onDetach() + presenter.onDetachView() + } + + override fun observeSearchResults(searchResults: LiveData>) { + this.searchResults?.removeObservers(viewLifecycleOwner) + this.searchResults = searchResults + searchResults.observe(viewLifecycleOwner, Observer { + depictionsAdapter.submitList(it) + depictionNotFound.visibility = if (it.loadedCount == 0) VISIBLE else GONE + }) + } + + override fun setEmptyViewText(query: String) { + depictionNotFound.text = getString(R.string.depictions_not_found, query) + } + + override fun hideInitialLoadProgress() { + depictionSearchInitialLoadProgress.visibility = GONE + } + + override fun showInitialLoadInProgress() { + depictionSearchInitialLoadProgress.visibility = VISIBLE + } + + override fun showSnackbar() { + ViewUtil.showShortSnackbar(depictionsSearchResultsList, R.string.error_loading_depictions) + } + + fun onQueryUpdated(query: String) { + presenter.onQueryUpdated(query) + } +} + +private val Fragment.isPortrait get() = orientation == Configuration.ORIENTATION_PORTRAIT + +private val Fragment.orientation get() = activity!!.resources.configuration.orientation diff --git a/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragmentContract.java b/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragmentContract.java deleted file mode 100644 index 8b041bb83..000000000 --- a/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragmentContract.java +++ /dev/null @@ -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 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 { - - /** - * 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(); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragmentContract.kt b/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragmentContract.kt new file mode 100644 index 000000000..69ade7c7b --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragmentContract.kt @@ -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>) + fun setEmptyViewText(query: String) + fun showInitialLoadInProgress() + fun hideInitialLoadProgress() + } + + interface UserActionListener : BasePresenter { + val listFooterData: LiveData> + fun onQueryUpdated(query: String) + fun retryFailedRequest() + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragmentPresenter.java b/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragmentPresenter.java deleted file mode 100644 index 5edaa5c3e..000000000 --- a/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragmentPresenter.java +++ /dev/null @@ -1,158 +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 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 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(); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragmentPresenter.kt b/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragmentPresenter.kt new file mode 100644 index 000000000..b4d02409c --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchDepictionsFragmentPresenter.kt @@ -0,0 +1,73 @@ +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>().apply { value = emptyList() } + + override fun onAttachView(view: SearchDepictionsFragmentContract.View) { + this.view = view + compositeDisposable.addAll( + searchableDataSourceFactory.searchResults.subscribe(view::observeSearchResults), + searchableDataSourceFactory.loadingStates + .observeOn(mainThreadScheduler) + .subscribe(::onLoadingState, Timber::e), + searchableDataSourceFactory.noItemsLoadedEvent.subscribe { + setEmptyViewText() + } + ) + } + + private fun onLoadingState(it: LoadingState) = when (it) { + LoadingState.Loading -> listFooterData.postValue(listOf(FooterItem.LoadingItem)) + LoadingState.Complete -> { + listFooterData.postValue(emptyList()) + view.hideInitialLoadProgress() + } + LoadingState.InitialLoad -> view.showInitialLoadInProgress() + LoadingState.Error -> { + setEmptyViewText() + view.showSnackbar() + view.hideInitialLoadProgress() + listFooterData.postValue(listOf(FooterItem.RefreshItem)) + } + } + + private fun setEmptyViewText() { + currentQuery?.let(view::setEmptyViewText) + } + + override fun retryFailedRequest() { + searchableDataSourceFactory.retryFailedRequest() + } + + override fun onDetachView() { + view = DUMMY + compositeDisposable.clear() + } + + override fun onQueryUpdated(query: String) { + currentQuery = query + searchableDataSourceFactory.onQueryUpdated(query) + } + + companion object { + private val DUMMY: SearchDepictionsFragmentContract.View = proxy() + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchableDepictionsDataSourceFactory.kt b/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchableDepictionsDataSourceFactory.kt new file mode 100644 index 000000000..90bdcdc40 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/explore/depictions/SearchableDepictionsDataSourceFactory.kt @@ -0,0 +1,88 @@ +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, + val liveDataConverter: LiveDataConverter +) { + private val _loadingStates = PublishProcessor.create() + val loadingStates: Flowable = _loadingStates + private val _searchResults = PublishProcessor.create>>() + val searchResults: Flowable>> = _searchResults + private val _noItemsLoadedEvent = PublishProcessor.create() + val noItemsLoadedEvent: Flowable = _noItemsLoadedEvent + + private var currentFactory: SearchDepictionsDataSourceFactory? = null + + fun onQueryUpdated(query: String) { + _searchResults.offer( + liveDataConverter.convert( + searchDepictionsDataSourceFactoryFactory.create(query, _loadingStates) + .also { currentFactory = it } + ) { _noItemsLoadedEvent.offer(Unit) } + ) + } + + fun retryFailedRequest() { + currentFactory?.retryFailedRequest() + } +} + +class LiveDataConverter @Inject constructor() { + fun convert( + dataSourceFactory: SearchDepictionsDataSourceFactory, + zeroItemsLoadedFunction: () -> Unit + ): LiveData> { + return dataSourceFactory.toLiveData( + Config( + pageSize = PAGE_SIZE, + initialLoadSizeHint = INITIAL_LOAD_SIZE, + enablePlaceholders = false + ), + boundaryCallback = object : PagedList.BoundaryCallback() { + override fun onZeroItemsLoaded() { + zeroItemsLoadedFunction() + } + } + ) + } + +} + +interface SearchDepictionsDataSourceFactoryFactory { + fun create(query: String, loadingStates: PublishProcessor) + : SearchDepictionsDataSourceFactory +} + +class SearchDepictionsDataSourceFactory constructor( + private val depictsClient: DepictsClient, + private val query: String, + private val loadingStates: PublishProcessor +) : DataSource.Factory() { + private var currentDataSource: SearchDepictionsDataSource? = null + override fun create() = SearchDepictionsDataSource(depictsClient, loadingStates, query) + .also { currentDataSource = it } + + fun retryFailedRequest() { + currentDataSource?.retryFailedRequest() + } +} + +sealed class LoadingState { + object InitialLoad : LoadingState() + object Loading : LoadingState() + object Complete : LoadingState() + object Error : LoadingState() +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsPresenter.kt b/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsPresenter.kt index c9b8bde66..3afc8ecf2 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsPresenter.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsPresenter.kt @@ -109,5 +109,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 proxy() = Proxy .newProxyInstance(T::class.java.classLoader, arrayOf(T::class.java)) { _, _, _ -> null } as T diff --git a/app/src/main/res/layout/fragment_search_depictions.xml b/app/src/main/res/layout/fragment_search_depictions.xml new file mode 100644 index 000000000..c87bbafcd --- /dev/null +++ b/app/src/main/res/layout/fragment_search_depictions.xml @@ -0,0 +1,29 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/item_depictions.xml b/app/src/main/res/layout/item_depictions.xml index a13539b85..cfae437c5 100644 --- a/app/src/main/res/layout/item_depictions.xml +++ b/app/src/main/res/layout/item_depictions.xml @@ -1,37 +1,43 @@ + + + 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" /> - + - - - + diff --git a/app/src/main/res/layout/list_item_load_more.xml b/app/src/main/res/layout/list_item_load_more.xml new file mode 100644 index 000000000..519b543b3 --- /dev/null +++ b/app/src/main/res/layout/list_item_load_more.xml @@ -0,0 +1,17 @@ + + +