package cl.monsoon.s1next.view.fragment; import android.content.Context; import android.databinding.DataBindingUtil; import android.os.Bundle; import android.support.annotation.CallSuper; import android.support.annotation.Nullable; import android.support.annotation.StringRes; import android.support.design.widget.Snackbar; import android.support.v4.app.Fragment; import android.support.v4.app.FragmentManager; import android.support.v4.widget.SwipeRefreshLayout; import android.support.v7.widget.RecyclerView; import android.text.TextUtils; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import com.google.common.base.Optional; import com.google.common.base.Preconditions; import java.lang.ref.WeakReference; import cl.monsoon.s1next.App; import cl.monsoon.s1next.R; import cl.monsoon.s1next.data.api.S1Service; import cl.monsoon.s1next.data.api.UserValidator; import cl.monsoon.s1next.data.api.model.Result; import cl.monsoon.s1next.databinding.FragmentBaseBinding; import cl.monsoon.s1next.util.ErrorUtil; import cl.monsoon.s1next.util.RxJavaUtil; import cl.monsoon.s1next.view.fragment.headless.DataRetainedFragment; import cl.monsoon.s1next.view.internal.CoordinatorLayoutAnchorDelegate; import cl.monsoon.s1next.view.internal.LoadingViewModelBindingDelegate; import cl.monsoon.s1next.view.internal.LoadingViewModelBindingDelegateBaseImpl; import cl.monsoon.s1next.viewmodel.LoadingViewModel; import rx.Observable; import rx.Subscription; import rx.android.schedulers.AndroidSchedulers; import rx.schedulers.Schedulers; /** * A base Fragment includes {@link SwipeRefreshLayout} to refresh when loading data. * Also wraps {@link retrofit2.Retrofit} to load data asynchronously. * <p> * We must call {@link #destroyRetainedFragment()}) if used in {@link android.support.v4.view.ViewPager} * otherwise leads memory leak. * * @param <D> The data we want to load. */ public abstract class BaseFragment<D> extends Fragment { /** * The serialization (saved instance state) Bundle key representing * current loading state. */ private static final String STATE_LOADING_VIEW_MODEL = "loading_view_model"; S1Service mS1Service; private LoadingViewModelBindingDelegate mLoadingViewModelBindingDelegate; private LoadingViewModel mLoadingViewModel; /** * We use retained Fragment to retain data when configuration changes. */ private DataRetainedFragment<D> mDataRetainedFragment; private Subscription mSubscription; private UserValidator mUserValidator; private CoordinatorLayoutAnchorDelegate mCoordinatorLayoutAnchorDelegate; @Nullable private WeakReference<Snackbar> mRetrySnackbar; @Override public void onAttach(Context context) { super.onAttach(context); mCoordinatorLayoutAnchorDelegate = (CoordinatorLayoutAnchorDelegate) context; } @Override public final View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { mLoadingViewModelBindingDelegate = getLoadingViewModelBindingDelegateImpl(inflater, container); return mLoadingViewModelBindingDelegate.getRootView(); } @Override @CallSuper public void onViewCreated(View view, Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); App.AppComponent appComponent = App.getAppComponent(getContext()); mS1Service = appComponent.getS1Service(); mUserValidator = appComponent.getUserValidator(); mLoadingViewModelBindingDelegate.getSwipeRefreshLayout().setOnRefreshListener( this::startSwipeRefresh); } @Override @CallSuper @SuppressWarnings("unchecked") public void onActivityCreated(Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); // Indicates that this Fragment would like to // influence the set of actions in the Toolbar. setHasOptionsMenu(true); if (savedInstanceState == null) { mLoadingViewModel = new LoadingViewModel(); } else { mLoadingViewModel = savedInstanceState.getParcelable(STATE_LOADING_VIEW_MODEL); } // because we can't retain Fragments that are nested in other Fragments // so we need to confirm this Fragment has unique tag in order to compose // a new unique tag for its retained Fragment. // Without this, we couldn't get its retained Fragment back. String dataRetainedFragmentTag = DataRetainedFragment.TAG + "_" + Preconditions.checkNotNull(getTag(), "Must add a tag to " + this + "."); FragmentManager fragmentManager = getFragmentManager(); Fragment fragment = fragmentManager.findFragmentByTag(dataRetainedFragmentTag); if (fragment == null) { mDataRetainedFragment = new DataRetainedFragment<>(); fragmentManager.beginTransaction().add(mDataRetainedFragment, dataRetainedFragmentTag) .commitAllowingStateLoss(); // start to load data because we start this Fragment the first time mLoadingViewModel.setLoading(LoadingViewModel.LOADING_FIRST_TIME); } else { mDataRetainedFragment = (DataRetainedFragment<D>) fragment; // get data back from retained Fragment when configuration changes if (mDataRetainedFragment.data != null) { int loading = mLoadingViewModel.getLoading(); onNext(mDataRetainedFragment.data); mLoadingViewModel.setLoading(loading); } else { if (!mDataRetainedFragment.stale) { // start to load data because the retained Fragment was killed by system // and we have no data to load mLoadingViewModel.setLoading(LoadingViewModel.LOADING_FIRST_TIME); } } } mLoadingViewModelBindingDelegate.setLoadingViewModel(mLoadingViewModel); if (isLoading()) { load(); } } @Override public void onDestroy() { super.onDestroy(); RxJavaUtil.unsubscribeIfNotNull(mSubscription); } @Override public void onDetach() { super.onDetach(); mCoordinatorLayoutAnchorDelegate = null; } @Override @CallSuper public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { inflater.inflate(R.menu.fragment_base, menu); } @Override @CallSuper public void onPrepareOptionsMenu(Menu menu) { // Disables the refresh menu when loading data. menu.findItem(R.id.menu_refresh).setEnabled(!isLoading()); } @Override @CallSuper public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { case R.id.menu_refresh: startSwipeRefresh(); return true; default: return super.onOptionsItemSelected(item); } } @Override @CallSuper public void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putParcelable(STATE_LOADING_VIEW_MODEL, mLoadingViewModel); } @Override public void setUserVisibleHint(boolean isVisibleToUser) { super.setUserVisibleHint(isVisibleToUser); // see http://stackoverflow.com/a/9779971 if (isVisible() && !isVisibleToUser) { // dismiss retry Snackbar when current Fragment hid // because this Snackbar is unrelated to other Fragments dismissRetrySnackbarIfExist(); } } /** * Subclass can override this in order to provider different * layout for {@link LoadingViewModelBindingDelegate}. */ LoadingViewModelBindingDelegate getLoadingViewModelBindingDelegateImpl(LayoutInflater inflater, ViewGroup container) { FragmentBaseBinding binding = DataBindingUtil.inflate(inflater, R.layout.fragment_base, container, false); return new LoadingViewModelBindingDelegateBaseImpl(binding); } /** * Whether we are loading data now. */ final boolean isLoading() { return mLoadingViewModel.getLoading() != LoadingViewModel.LOADING_FINISH; } /** * Whether we are pulling up to refresh. */ final boolean isPullUpToRefresh() { return mLoadingViewModel.getLoading() == LoadingViewModel.LOADING_PULL_UP_TO_REFRESH; } /** * Show refresh progress and start to load new data. */ private void startSwipeRefresh() { mLoadingViewModel.setLoading(LoadingViewModel.LOADING_SWIPE_REFRESH); load(); } /** * Disables {@link SwipeRefreshLayout} and start to load new data. * <p> * Subclass should override this method and add {@link android.widget.ProgressBar} * to {@code getRecyclerView()} in order to let {@link #showRetrySnackbar(CharSequence)} * work. */ @CallSuper void startPullToRefresh() { mLoadingViewModel.setLoading(LoadingViewModel.LOADING_PULL_UP_TO_REFRESH); load(); } /** * Starts to load new data. * <p> * Subclass should implement {@link #getSourceObservable()} * in oder to provider its own data source {@link Observable}. */ private void load() { // dismiss Snackbar in order to let user see the ProgressBar // when we start to load new data mCoordinatorLayoutAnchorDelegate.dismissSnackbarIfExist(); mSubscription = getSourceObservable().subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .doOnNext(mUserValidator::validateIntercept) .doAfterTerminate(this::doAfterTerminate) .subscribe(this::onNext, this::onError); } /** * Subclass should implement this in order to provider its * data source {@link Observable}. * <p> * The data source {@link Observable} often comes from network * or database. * * @return The data source {@link Observable}. */ abstract Observable<D> getSourceObservable(); /** * Called when a data was emitted from {@link #getSourceObservable()}. * <p> * Actually this method was only called once during loading (if no error occurs) * because we only emit data once from {@link #getSourceObservable()}. */ @CallSuper void onNext(D data) { mDataRetainedFragment.data = data; } /** * A helper method consumes {@link Result}. * <p> * Sometimes we cannot get data if we have logged out or * have no permission to access this data. * This method is only used during {@link #onNext(Object)}. * * @param result The data's result we get. */ final void consumeResult(Result result) { if (getUserVisibleHint()) { String message = result.getMessage(); if (!TextUtils.isEmpty(message)) { showRetrySnackbar(message); } } } /** * Called when an error occurs during data loading. * <p> * This stops the {@link #getSourceObservable()} and it will not make * further calls to {@link #onNext(Object)}. */ @CallSuper void onError(Throwable throwable) { if (getUserVisibleHint()) { showRetrySnackbar(ErrorUtil.parse(throwable)); } } /** * Called if it will not make further calls to {@link #onNext(Object)} * or {@link #onError(Throwable)} occurred during data loading. */ private void doAfterTerminate() { mLoadingViewModel.setLoading(LoadingViewModel.LOADING_FINISH); mDataRetainedFragment.stale = true; } private void showRetrySnackbar(CharSequence text) { Optional<Snackbar> snackbar = mCoordinatorLayoutAnchorDelegate.showLongSnackbarIfVisible( text, R.string.snackbar_action_retry, isPullUpToRefresh() ? v -> startPullToRefresh() : v -> startSwipeRefresh()); if (snackbar.isPresent()) { mRetrySnackbar = new WeakReference<>(snackbar.get()); } } private void showRetrySnackbar(@StringRes int textResId) { showRetrySnackbar(getString(textResId)); } private void dismissRetrySnackbarIfExist() { if (mRetrySnackbar != null) { Snackbar snackbar = mRetrySnackbar.get(); if (snackbar != null && snackbar.isShownOrQueued()) { snackbar.dismiss(); } mRetrySnackbar = null; } } final RecyclerView getRecyclerView() { return mLoadingViewModelBindingDelegate.getRecyclerView(); } /** * We must call this if used in {@link android.support.v4.view.ViewPager} * otherwise leads memory leak. */ public final void destroyRetainedFragment() { if (mDataRetainedFragment != null) { getFragmentManager().beginTransaction().remove(mDataRetainedFragment).commit(); } } }