package org.wordpress.android.ui.posts; import android.app.Activity; import android.app.Fragment; import android.content.Context; import android.content.Intent; import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.design.widget.Snackbar; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.ProgressBar; import android.widget.TextView; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; import org.wordpress.android.R; import org.wordpress.android.WordPress; import org.wordpress.android.fluxc.Dispatcher; import org.wordpress.android.fluxc.generated.PostActionBuilder; import org.wordpress.android.fluxc.model.MediaModel; import org.wordpress.android.fluxc.model.PostModel; import org.wordpress.android.fluxc.model.SiteModel; import org.wordpress.android.fluxc.model.post.PostStatus; import org.wordpress.android.fluxc.store.MediaStore; import org.wordpress.android.fluxc.store.PostStore; import org.wordpress.android.fluxc.store.PostStore.FetchPostsPayload; import org.wordpress.android.fluxc.store.PostStore.OnPostChanged; import org.wordpress.android.fluxc.store.PostStore.OnPostUploaded; import org.wordpress.android.fluxc.store.PostStore.PostError; import org.wordpress.android.fluxc.store.PostStore.RemotePostPayload; import org.wordpress.android.fluxc.store.SiteStore; import org.wordpress.android.push.NativeNotificationsUtils; import org.wordpress.android.ui.ActivityLauncher; import org.wordpress.android.ui.EmptyViewMessageType; import org.wordpress.android.ui.notifications.utils.PendingDraftsNotificationsUtils; import org.wordpress.android.ui.posts.adapters.PostsListAdapter; import org.wordpress.android.ui.posts.adapters.PostsListAdapter.LoadMode; import org.wordpress.android.ui.posts.services.PostEvents; import org.wordpress.android.ui.posts.services.PostUploadService; import org.wordpress.android.util.AniUtils; import org.wordpress.android.util.NetworkUtils; import org.wordpress.android.util.ToastUtils; import org.wordpress.android.util.helpers.SwipeToRefreshHelper; import org.wordpress.android.util.helpers.SwipeToRefreshHelper.RefreshListener; import org.wordpress.android.util.widgets.CustomSwipeRefreshLayout; import org.wordpress.android.widgets.PostListButton; import org.wordpress.android.widgets.RecyclerItemDecoration; import java.util.ArrayList; import java.util.List; import javax.inject.Inject; import de.greenrobot.event.EventBus; public class PostsListFragment extends Fragment implements PostsListAdapter.OnPostsLoadedListener, PostsListAdapter.OnLoadMoreListener, PostsListAdapter.OnPostSelectedListener, PostsListAdapter.OnPostButtonClickListener { public static final int POSTS_REQUEST_COUNT = 20; private SwipeToRefreshHelper mSwipeToRefreshHelper; private PostsListAdapter mPostsListAdapter; private View mFabView; private RecyclerView mRecyclerView; private View mEmptyView; private ProgressBar mProgressLoadMore; private TextView mEmptyViewTitle; private ImageView mEmptyViewImage; private boolean mCanLoadMorePosts = true; private boolean mIsPage; private boolean mIsFetchingPosts; private boolean mShouldCancelPendingDraftNotification = false; private int mPostIdForPostToBeDeleted = 0; private final List<PostModel> mTrashedPosts = new ArrayList<>(); private SiteModel mSite; @Inject SiteStore mSiteStore; @Inject PostStore mPostStore; @Inject Dispatcher mDispatcher; public static PostsListFragment newInstance(SiteModel site) { PostsListFragment fragment = new PostsListFragment(); Bundle bundle = new Bundle(); bundle.putSerializable(WordPress.SITE, site); fragment.setArguments(bundle); return fragment; } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); ((WordPress) getActivity().getApplication()).component().inject(this); EventBus.getDefault().register(this); mDispatcher.register(this); updateSiteOrFinishActivity(savedInstanceState); if (isAdded()) { Bundle extras = getActivity().getIntent().getExtras(); if (extras != null) { mIsPage = extras.getBoolean(PostsListActivity.EXTRA_VIEW_PAGES); } } } @Override public void onDestroy() { EventBus.getDefault().unregister(this); mDispatcher.unregister(this); super.onDestroy(); } private void updateSiteOrFinishActivity(Bundle savedInstanceState) { if (savedInstanceState == null) { if (getArguments() != null) { mSite = (SiteModel) getArguments().getSerializable(WordPress.SITE); } else { mSite = (SiteModel) getActivity().getIntent().getSerializableExtra(WordPress.SITE); } } else { mSite = (SiteModel) savedInstanceState.getSerializable(WordPress.SITE); } if (mSite == null) { ToastUtils.showToast(getActivity(), R.string.blog_not_found, ToastUtils.Duration.SHORT); getActivity().finish(); } } @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View view = inflater.inflate(R.layout.post_list_fragment, container, false); mRecyclerView = (RecyclerView) view.findViewById(R.id.recycler_view); mProgressLoadMore = (ProgressBar) view.findViewById(R.id.progress); mFabView = view.findViewById(R.id.fab_button); mEmptyView = view.findViewById(R.id.empty_view); mEmptyViewTitle = (TextView) mEmptyView.findViewById(R.id.title_empty); mEmptyViewImage = (ImageView) mEmptyView.findViewById(R.id.image_empty); Context context = getActivity(); mRecyclerView.setLayoutManager(new LinearLayoutManager(context)); int spacingVertical = mIsPage ? 0 : context.getResources().getDimensionPixelSize(R.dimen.card_gutters); int spacingHorizontal = context.getResources().getDimensionPixelSize(R.dimen.content_margin); mRecyclerView.addItemDecoration(new RecyclerItemDecoration(spacingHorizontal, spacingVertical)); // hide the fab so we can animate it in - note that we only do this on Lollipop and higher // due to a bug in the current implementation which prevents it from being hidden // correctly on pre-L devices (which makes animating it in/out ugly) // https://code.google.com/p/android/issues/detail?id=175331 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { mFabView.setVisibility(View.GONE); } mFabView.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { newPost(); } }); if (savedInstanceState == null) { requestPosts(false); } initSwipeToRefreshHelper(view); return view; } public void handleEditPostResult(int resultCode, Intent data) { if (resultCode == Activity.RESULT_OK && data != null && isAdded()) { boolean hasChanges = data.getBooleanExtra(EditPostActivity.EXTRA_HAS_CHANGES, false); final PostModel post = (PostModel)data.getSerializableExtra(EditPostActivity.EXTRA_POST); boolean isPublishable = post != null && PostUtils.isPublishable(post); boolean savedLocally = data.getBooleanExtra(EditPostActivity.EXTRA_SAVED_AS_LOCAL_DRAFT, false); boolean hasUnfinishedMedia = data.getBooleanExtra(EditPostActivity.EXTRA_HAS_UNFINISHED_MEDIA, false); if (hasChanges) { if (savedLocally && !NetworkUtils.isNetworkAvailable(getActivity())) { ToastUtils.showToast(getActivity(), R.string.error_publish_no_network, ToastUtils.Duration.SHORT); } else { if (isPublishable) { if (hasUnfinishedMedia) { Snackbar.make(getActivity().findViewById(R.id.coordinator), R.string.editor_post_saved_locally_unfinished_media, Snackbar.LENGTH_LONG) .setAction(R.string.button_edit, new View.OnClickListener() { @Override public void onClick(View v) { ActivityLauncher.editPostOrPageForResult(getActivity(), mSite, post); } }).show(); } else if (savedLocally) { Snackbar.make(getActivity().findViewById(R.id.coordinator), R.string.editor_post_saved_locally_not_published, Snackbar.LENGTH_LONG) .setAction(R.string.button_publish, new View.OnClickListener() { @Override public void onClick(View v) { publishPost(post); } }).show(); } else if (PostStatus.fromPost(post) == PostStatus.DRAFT) { Snackbar.make(getActivity().findViewById(R.id.coordinator), R.string.editor_post_saved_online_not_published, Snackbar.LENGTH_LONG) .setAction(R.string.button_publish, new View.OnClickListener() { @Override public void onClick(View v) { publishPost(post); } }).show(); } } else { if (savedLocally) { ToastUtils.showToast(getActivity(), R.string.editor_post_saved_locally); } else { ToastUtils.showToast(getActivity(), R.string.editor_post_saved_online); } } } } } } private void initSwipeToRefreshHelper(View view) { mSwipeToRefreshHelper = new SwipeToRefreshHelper( getActivity(), (CustomSwipeRefreshLayout) view.findViewById(R.id.ptr_layout), new RefreshListener() { @Override public void onRefreshStarted() { if (!isAdded()) { return; } if (!NetworkUtils.checkConnection(getActivity())) { setRefreshing(false); updateEmptyView(EmptyViewMessageType.NETWORK_ERROR); return; } requestPosts(false); } }); } private @Nullable PostsListAdapter getPostListAdapter() { if (mPostsListAdapter == null) { mPostsListAdapter = new PostsListAdapter(getActivity(), mSite, mIsPage); mPostsListAdapter.setOnLoadMoreListener(this); mPostsListAdapter.setOnPostsLoadedListener(this); mPostsListAdapter.setOnPostSelectedListener(this); mPostsListAdapter.setOnPostButtonClickListener(this); } return mPostsListAdapter; } private boolean isPostAdapterEmpty() { return (mPostsListAdapter != null && mPostsListAdapter.getItemCount() == 0); } private void loadPosts(LoadMode mode) { if (getPostListAdapter() != null) { getPostListAdapter().loadPosts(mode); } } private void newPost() { if (!isAdded()) return; ActivityLauncher.addNewPostOrPageForResult(getActivity(), mSite, mIsPage); } public void onResume() { super.onResume(); if (getPostListAdapter() != null && mRecyclerView.getAdapter() == null) { mRecyclerView.setAdapter(getPostListAdapter()); } // always (re)load when resumed to reflect changes made elsewhere loadPosts(LoadMode.IF_CHANGED); // scale in the fab after a brief delay if it's not already showing if (mFabView.getVisibility() != View.VISIBLE) { long delayMs = getResources().getInteger(R.integer.fab_animation_delay); new Handler().postDelayed(new Runnable() { @Override public void run() { if (isAdded()) { AniUtils.scaleIn(mFabView, AniUtils.Duration.MEDIUM); } } }, delayMs); } } public boolean isRefreshing() { return mSwipeToRefreshHelper.isRefreshing(); } private void setRefreshing(boolean refreshing) { mSwipeToRefreshHelper.setRefreshing(refreshing); } private void requestPosts(boolean loadMore) { if (!isAdded() || mIsFetchingPosts) { return; } if (!NetworkUtils.isNetworkAvailable(getActivity())) { updateEmptyView(EmptyViewMessageType.NETWORK_ERROR); return; } if (getPostListAdapter() != null && getPostListAdapter().getItemCount() == 0) { updateEmptyView(EmptyViewMessageType.LOADING); } mIsFetchingPosts = true; if (loadMore) { showLoadMoreProgress(); } FetchPostsPayload payload = new FetchPostsPayload(mSite, loadMore); if (mIsPage) { mDispatcher.dispatch(PostActionBuilder.newFetchPagesAction(payload)); } else { mDispatcher.dispatch(PostActionBuilder.newFetchPostsAction(payload)); } } private void showLoadMoreProgress() { if (mProgressLoadMore != null) { mProgressLoadMore.setVisibility(View.VISIBLE); } } private void hideLoadMoreProgress() { if (mProgressLoadMore != null) { mProgressLoadMore.setVisibility(View.GONE); } } /* * upload start, reload so correct status on uploading post appears */ @SuppressWarnings("unused") public void onEventMainThread(PostEvents.PostUploadStarted event) { if (isAdded() && mSite.getId() == event.mLocalBlogId) { loadPosts(LoadMode.FORCED); } } private void updateEmptyView(EmptyViewMessageType emptyViewMessageType) { int stringId; switch (emptyViewMessageType) { case LOADING: stringId = mIsPage ? R.string.pages_fetching : R.string.posts_fetching; break; case NO_CONTENT: stringId = mIsPage ? R.string.pages_empty_list : R.string.posts_empty_list; break; case NETWORK_ERROR: stringId = R.string.no_network_message; break; case PERMISSION_ERROR: stringId = mIsPage ? R.string.error_refresh_unauthorized_pages : R.string.error_refresh_unauthorized_posts; break; case GENERIC_ERROR: stringId = mIsPage ? R.string.error_refresh_pages : R.string.error_refresh_posts; break; default: return; } mEmptyViewTitle.setText(getText(stringId)); mEmptyViewImage.setVisibility(emptyViewMessageType == EmptyViewMessageType.NO_CONTENT ? View.VISIBLE : View.GONE); mEmptyView.setVisibility(isPostAdapterEmpty() ? View.VISIBLE : View.GONE); } private void hideEmptyView() { if (isAdded() && mEmptyView != null) { mEmptyView.setVisibility(View.GONE); } } @Override public void onDetach() { if (mShouldCancelPendingDraftNotification) { // delete the pending draft notification if available int pushId = PendingDraftsNotificationsUtils.makePendingDraftNotificationId(mPostIdForPostToBeDeleted); NativeNotificationsUtils.dismissNotification(pushId, getActivity()); mShouldCancelPendingDraftNotification = false; } super.onDetach(); } /* * called by the adapter after posts have been loaded */ @Override public void onPostsLoaded(int postCount) { if (!isAdded()) { return; } if (postCount == 0 && !mIsFetchingPosts) { if (NetworkUtils.isNetworkAvailable(getActivity())) { updateEmptyView(EmptyViewMessageType.NO_CONTENT); } else { updateEmptyView(EmptyViewMessageType.NETWORK_ERROR); } } else if (postCount > 0) { hideEmptyView(); } } /* * called by the adapter to load more posts when the user scrolls towards the last post */ @Override public void onLoadMore() { if (mCanLoadMorePosts && !mIsFetchingPosts) { requestPosts(true); } } /* * called by the adapter when the user clicks a post */ @Override public void onPostSelected(PostModel post) { onPostButtonClicked(PostListButton.BUTTON_EDIT, post); } /* * called by the adapter when the user clicks the edit/view/stats/trash button for a post */ @Override public void onPostButtonClicked(int buttonType, PostModel post) { if (!isAdded()) return; switch (buttonType) { case PostListButton.BUTTON_EDIT: ActivityLauncher.editPostOrPageForResult(getActivity(), mSite, post); break; case PostListButton.BUTTON_SUBMIT: case PostListButton.BUTTON_PUBLISH: publishPost(post); break; case PostListButton.BUTTON_VIEW: ActivityLauncher.browsePostOrPage(getActivity(), mSite, post); break; case PostListButton.BUTTON_PREVIEW: ActivityLauncher.viewPostPreviewForResult(getActivity(), mSite, post, mIsPage); break; case PostListButton.BUTTON_STATS: ActivityLauncher.viewStatsSinglePostDetails(getActivity(), mSite, post, mIsPage); break; case PostListButton.BUTTON_TRASH: case PostListButton.BUTTON_DELETE: // prevent deleting post while it's being uploaded if (!PostUploadService.isPostUploading(post)) { trashPost(post); } break; } } private void publishPost(final PostModel post) { if (!NetworkUtils.isNetworkAvailable(getActivity())) { ToastUtils.showToast(getActivity(), R.string.error_publish_no_network, ToastUtils.Duration.SHORT); return; } // If the post is empty, don't publish if (!PostUtils.isPublishable(post)) { ToastUtils.showToast(getActivity(), R.string.error_publish_empty_post, ToastUtils.Duration.SHORT); return; } post.setStatus(PostStatus.PUBLISHED.toString()); PostUploadService.addPostToUpload(post); getActivity().startService(new Intent(getActivity(), PostUploadService.class)); PostUtils.trackSavePostAnalytics(post, mSite); } /* * send the passed post to the trash with undo */ private void trashPost(final PostModel post) { //only check if network is available in case this is not a local draft - local drafts have not yet //been posted to the server so they can be trashed w/o further care if (!isAdded() || (!post.isLocalDraft() && !NetworkUtils.checkConnection(getActivity())) || getPostListAdapter() == null) { return; } // remove post from the list and add it to the list of trashed posts getPostListAdapter().hidePost(post); mTrashedPosts.add(post); // make sure empty view shows if user deleted the only post if (getPostListAdapter().getItemCount() == 0) { updateEmptyView(EmptyViewMessageType.NO_CONTENT); } View.OnClickListener undoListener = new View.OnClickListener() { @Override public void onClick(View v) { // user undid the trash, so unhide the post and remove it from the list of trashed posts mTrashedPosts.remove(post); getPostListAdapter().unhidePost(post); hideEmptyView(); } }; // different undo text if this is a local draft since it will be deleted rather than trashed String text; if (post.isLocalDraft()) { text = mIsPage ? getString(R.string.page_deleted) : getString(R.string.post_deleted); } else { text = mIsPage ? getString(R.string.page_trashed) : getString(R.string.post_trashed); } Snackbar snackbar = Snackbar.make(getView().findViewById(R.id.coordinator), text, Snackbar.LENGTH_LONG) .setAction(R.string.undo, undoListener); // wait for the undo snackbar to disappear before actually deleting the post snackbar.setCallback(new Snackbar.Callback() { @Override public void onDismissed(Snackbar snackbar, int event) { super.onDismissed(snackbar, event); // if the post no longer exists in the list of trashed posts it's because the // user undid the trash, so don't perform the deletion if (!mTrashedPosts.contains(post)) { return; } // remove from the list of trashed posts in case onDismissed is called multiple // times - this way the above check prevents us making the call to delete it twice // https://code.google.com/p/android/issues/detail?id=190529 mTrashedPosts.remove(post); if (post.isLocalDraft()) { mDispatcher.dispatch(PostActionBuilder.newRemovePostAction(post)); // delete the pending draft notification if available mShouldCancelPendingDraftNotification = false; int pushId = PendingDraftsNotificationsUtils.makePendingDraftNotificationId(post.getId()); NativeNotificationsUtils.dismissNotification(pushId, getActivity()); } else { mDispatcher.dispatch(PostActionBuilder.newDeletePostAction(new RemotePostPayload(post, mSite))); } } }); mPostIdForPostToBeDeleted = post.getId(); mShouldCancelPendingDraftNotification = true; snackbar.show(); } @Override public void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putSerializable(WordPress.SITE, mSite); } @SuppressWarnings("unused") @Subscribe(threadMode = ThreadMode.MAIN) public void onPostChanged(OnPostChanged event) { switch (event.causeOfChange) { case FETCH_POSTS: case FETCH_PAGES: mIsFetchingPosts = false; if (!isAdded()) { return; } setRefreshing(false); hideLoadMoreProgress(); if (!event.isError()) { mCanLoadMorePosts = event.canLoadMore; loadPosts(LoadMode.IF_CHANGED); } else { PostError error = event.error; switch (error.type) { case UNAUTHORIZED: updateEmptyView(EmptyViewMessageType.PERMISSION_ERROR); break; default: updateEmptyView(EmptyViewMessageType.GENERIC_ERROR); break; } } break; case DELETE_POST: if (event.isError()) { String message = String.format(getText(R.string.error_delete_post).toString(), mIsPage ? "page" : "post"); ToastUtils.showToast(getActivity(), message, ToastUtils.Duration.SHORT); loadPosts(LoadMode.IF_CHANGED); } break; } } @SuppressWarnings("unused") @Subscribe(threadMode = ThreadMode.MAIN) public void onPostUploaded(OnPostUploaded event) { if (isAdded() && event.post.getLocalSiteId() == mSite.getId()) { loadPosts(LoadMode.FORCED); } } /* * Media info for a post's featured image has been downloaded, tell * the adapter so it can show the featured image now that we have its URL */ @SuppressWarnings("unused") @Subscribe(threadMode = ThreadMode.MAIN) public void onMediaChanged(MediaStore.OnMediaChanged event) { if (isAdded() && !event.isError() && mPostsListAdapter != null) { if (event.mediaList != null && event.mediaList.size() > 0) { MediaModel mediaModel = event.mediaList.get(0); mPostsListAdapter.mediaChanged(mediaModel); } } } }