mirror of
				https://github.com/commons-app/apps-android-commons.git
				synced 2025-10-30 22:34:02 +01:00 
			
		
		
		
	
							parent
							
								
									0ebd59a223
								
							
						
					
					
						commit
						c216fdf0d4
					
				
					 33 changed files with 1035 additions and 616 deletions
				
			
		|  | @ -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 | ||||
|  |  | |||
|  | @ -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 } | ||||
|     ) | ||||
|  | @ -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(); | ||||
| 
 | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -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); | ||||
|  |  | |||
|  | @ -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)) { | ||||
|  |  | |||
|  | @ -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); | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -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<DepictedItem>( | ||||
|     depictionDelegate(clickListener), | ||||
|     areItemsTheSame = { oldItem, newItem -> oldItem.id == newItem.id } | ||||
| ) | ||||
| 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) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -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) | ||||
|  | @ -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<LoadingState>, | ||||
|     private val query: String | ||||
| ) : PositionalDataSource<DepictedItem>() { | ||||
| 
 | ||||
|     private 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) | ||||
|     } | ||||
| } | ||||
|  | @ -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<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); | ||||
|     } | ||||
| } | ||||
|  | @ -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 | ||||
|  | @ -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(); | ||||
|     } | ||||
| } | ||||
|  | @ -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() | ||||
|     } | ||||
| } | ||||
|  | @ -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<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(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -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<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 { | ||||
|                 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() | ||||
|     } | ||||
| } | ||||
|  | @ -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<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 | ||||
| 
 | ||||
|     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<PagedList<DepictedItem>> { | ||||
|         return dataSourceFactory.toLiveData( | ||||
|             Config( | ||||
|                 pageSize = PAGE_SIZE, | ||||
|                 initialLoadSizeHint = INITIAL_LOAD_SIZE, | ||||
|                 enablePlaceholders = false | ||||
|             ), | ||||
|             boundaryCallback = object : PagedList.BoundaryCallback<DepictedItem>() { | ||||
|                 override fun onZeroItemsLoaded() { | ||||
|                     zeroItemsLoadedFunction() | ||||
|                 } | ||||
|             } | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| 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>() { | ||||
|     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() | ||||
| } | ||||
|  | @ -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 <reified T> proxy() = Proxy | ||||
|     .newProxyInstance(T::class.java.classLoader, arrayOf(T::class.java)) { _, _, _ -> null } as T | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Seán Mac Gillicuddy
						Seán Mac Gillicuddy