/*
* Copyright 2015 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.plaidapp.ui;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.annotation.TargetApi;
import android.app.Activity;
import android.app.ActivityOptions;
import android.app.PendingIntent;
import android.app.SharedElementCallback;
import android.app.assist.AssistContent;
import android.content.Context;
import android.content.Intent;
import android.graphics.Path;
import android.graphics.drawable.AnimatedVectorDrawable;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.customtabs.CustomTabsIntent;
import android.support.customtabs.CustomTabsSession;
import android.support.design.widget.TextInputLayout;
import android.support.v4.app.ShareCompat;
import android.support.v4.content.ContextCompat;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.format.DateUtils;
import android.text.style.TextAppearanceSpan;
import android.util.TypedValue;
import android.view.View;
import android.view.ViewAnimationUtils;
import android.view.ViewGroup;
import android.view.animation.Interpolator;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import android.widget.Toolbar;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import java.text.NumberFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import butterknife.BindDimen;
import butterknife.BindInt;
import butterknife.BindView;
import butterknife.ButterKnife;
import in.uncod.android.bypass.Bypass;
import in.uncod.android.bypass.style.ImageLoadingSpan;
import io.plaidapp.R;
import io.plaidapp.data.api.designernews.UpvoteStoryService;
import io.plaidapp.data.api.designernews.model.Comment;
import io.plaidapp.data.api.designernews.model.Story;
import io.plaidapp.data.prefs.DesignerNewsPrefs;
import io.plaidapp.ui.drawable.ThreadedCommentDrawable;
import io.plaidapp.ui.recyclerview.SlideInItemAnimator;
import io.plaidapp.ui.transitions.GravityArcMotion;
import io.plaidapp.ui.transitions.MorphTransform;
import io.plaidapp.ui.transitions.ReflowText;
import io.plaidapp.ui.widget.AuthorTextView;
import io.plaidapp.ui.widget.CollapsingTitleLayout;
import io.plaidapp.ui.widget.ElasticDragDismissFrameLayout;
import io.plaidapp.ui.widget.PinnedOffsetView;
import io.plaidapp.util.HtmlUtils;
import io.plaidapp.util.ImageUtils;
import io.plaidapp.util.ImeUtils;
import io.plaidapp.util.ViewUtils;
import io.plaidapp.util.customtabs.CustomTabActivityHelper;
import io.plaidapp.util.glide.CircleTransform;
import io.plaidapp.util.glide.ImageSpanTarget;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
import static io.plaidapp.util.AnimUtils.getFastOutLinearInInterpolator;
import static io.plaidapp.util.AnimUtils.getFastOutSlowInInterpolator;
import static io.plaidapp.util.AnimUtils.getLinearOutSlowInInterpolator;
public class DesignerNewsStory extends Activity {
protected static final String EXTRA_STORY = "story";
private static final int RC_LOGIN_UPVOTE = 7;
private View header;
@BindView(R.id.comments_list) RecyclerView commentsList;
private LinearLayoutManager layoutManager;
private DesignerNewsCommentsAdapter commentsAdapter;
@BindView(R.id.fab) ImageButton fab;
@BindView(R.id.fab_expand) View fabExpand;
@BindView(R.id.comments_container) ElasticDragDismissFrameLayout draggableFrame;
private ElasticDragDismissFrameLayout.SystemChromeFader chromeFader;
@Nullable @BindView(R.id.backdrop_toolbar) CollapsingTitleLayout collapsingToolbar;
@Nullable @BindView(R.id.story_title_background) PinnedOffsetView toolbarBackground;
@Nullable @BindView(R.id.background) View background;
private TextView upvoteStory;
private EditText enterComment;
private ImageButton postComment;
@BindInt(R.integer.fab_expand_duration) int fabExpandDuration;
@BindDimen(R.dimen.comment_thread_width) int threadWidth;
@BindDimen(R.dimen.comment_thread_gap) int threadGap;
private Story story;
private DesignerNewsPrefs designerNewsPrefs;
private Bypass markdown;
private CustomTabActivityHelper customTab;
private CircleTransform circleTransform;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_designer_news_story);
ButterKnife.bind(this);
story = getIntent().getParcelableExtra(EXTRA_STORY);
fab.setOnClickListener(fabClick);
chromeFader = new ElasticDragDismissFrameLayout.SystemChromeFader(this);
markdown = new Bypass(this, new Bypass.Options()
.setBlockQuoteLineColor(
ContextCompat.getColor(this, R.color.designer_news_quote_line))
.setBlockQuoteLineWidth(2) // dps
.setBlockQuoteLineIndent(8) // dps
.setPreImageLinebreakHeight(4) //dps
.setBlockQuoteIndentSize(TypedValue.COMPLEX_UNIT_DIP, 2f)
.setBlockQuoteTextColor(ContextCompat.getColor(this, R.color.designer_news_quote)));
circleTransform = new CircleTransform(this);
designerNewsPrefs = DesignerNewsPrefs.get(this);
layoutManager = new LinearLayoutManager(this);
commentsList.setLayoutManager(layoutManager);
commentsList.setItemAnimator(new CommentAnimator(
getResources().getInteger(R.integer.comment_expand_collapse_duration)));
header = getLayoutInflater().inflate(
R.layout.designer_news_story_description, commentsList, false);
bindDescription();
// setup title/toolbar
if (collapsingToolbar != null) { // narrow device: collapsing toolbar
collapsingToolbar.addOnLayoutChangeListener(titlebarLayout);
collapsingToolbar.setTitle(story.title);
final Toolbar toolbar = (Toolbar) findViewById(R.id.story_toolbar);
toolbar.setNavigationOnClickListener(backClick);
commentsList.addOnScrollListener(headerScrollListener);
setEnterSharedElementCallback(new SharedElementCallback() {
@Override
public void onSharedElementStart(List<String> sharedElementNames, List<View>
sharedElements, List<View> sharedElementSnapshots) {
ReflowText.setupReflow(getIntent(), collapsingToolbar);
}
@Override
public void onSharedElementEnd(List<String> sharedElementNames, List<View>
sharedElements, List<View> sharedElementSnapshots) {
ReflowText.setupReflow(collapsingToolbar);
}
});
} else { // w600dp configuration: content card scrolls over title bar
final TextView title = (TextView) findViewById(R.id.story_title);
title.setText(story.title);
findViewById(R.id.back).setOnClickListener(backClick);
}
final View enterCommentView = setupCommentField();
if (story.comment_count > 0) {
// flatten the comments from a nested structure {@see Comment#comments} to a
// list appropriate for our adapter (using the depth attribute).
List<Comment> flattened = new ArrayList<>(story.comment_count);
unnestComments(story.comments, flattened);
commentsAdapter =
new DesignerNewsCommentsAdapter(header, flattened, enterCommentView);
commentsList.setAdapter(commentsAdapter);
} else {
commentsAdapter = new DesignerNewsCommentsAdapter(
header, new ArrayList<Comment>(0), enterCommentView);
commentsList.setAdapter(commentsAdapter);
}
customTab = new CustomTabActivityHelper();
customTab.setConnectionCallback(customTabConnect);
}
@Override
protected void onStart() {
super.onStart();
customTab.bindCustomTabsService(this);
}
@Override
protected void onResume() {
super.onResume();
// clean up after any fab expansion
fab.setAlpha(1f);
fabExpand.setVisibility(View.INVISIBLE);
draggableFrame.addListener(chromeFader);
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
switch (requestCode) {
case RC_LOGIN_UPVOTE:
if (resultCode == RESULT_OK) {
upvoteStory();
}
break;
}
}
@Override
protected void onPause() {
draggableFrame.removeListener(chromeFader);
super.onPause();
}
@Override
protected void onStop() {
customTab.unbindCustomTabsService(this);
super.onStop();
}
@Override
protected void onDestroy() {
customTab.setConnectionCallback(null);
super.onDestroy();
}
@Override @TargetApi(Build.VERSION_CODES.M)
public void onProvideAssistContent(AssistContent outContent) {
outContent.setWebUri(Uri.parse(story.url));
}
public static CustomTabsIntent.Builder getCustomTabIntent(@NonNull Context context,
@NonNull Story story,
@Nullable CustomTabsSession session) {
Intent upvoteStory = new Intent(context, UpvoteStoryService.class);
upvoteStory.setAction(UpvoteStoryService.ACTION_UPVOTE);
upvoteStory.putExtra(UpvoteStoryService.EXTRA_STORY_ID, story.id);
PendingIntent pendingIntent = PendingIntent.getService(context, 0, upvoteStory, 0);
return new CustomTabsIntent.Builder(session)
.setToolbarColor(ContextCompat.getColor(context, R.color.designer_news))
.setActionButton(ImageUtils.vectorToBitmap(context,
R.drawable.ic_upvote_filled_24dp_white),
context.getString(R.string.upvote_story),
pendingIntent,
false)
.setShowTitle(true)
.enableUrlBarHiding()
.addDefaultShareMenuItem();
}
private final CustomTabActivityHelper.ConnectionCallback customTabConnect
= new CustomTabActivityHelper.ConnectionCallback() {
@Override
public void onCustomTabsConnected() {
customTab.mayLaunchUrl(Uri.parse(story.url), null, null);
}
@Override public void onCustomTabsDisconnected() { }
};
private final RecyclerView.OnScrollListener headerScrollListener
= new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
updateScrollDependentUi();
}
};
private final View.OnClickListener backClick = new View.OnClickListener() {
@Override
public void onClick(View view) {
finishAfterTransition();
}
};
private void updateScrollDependentUi() {
// feed scroll events to the header
if (collapsingToolbar != null) {
final int headerScroll = header.getTop() - commentsList.getPaddingTop();
collapsingToolbar.setScrollPixelOffset(-headerScroll);
toolbarBackground.setOffset(headerScroll);
}
updateFabVisibility();
}
private boolean fabIsVisible = true;
private void updateFabVisibility() {
// the FAB position can interfere with the enter comment field. Hide the FAB if:
// - The comment field is scrolled onto screen
// - The comment field is focused (i.e. stories with no/few comments might not push the
// enter comment field off-screen so need to make sure the button is accessible
// - A comment reply field is focused
final boolean enterCommentFocused = enterComment.isFocused();
final int firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition();
final int lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition();
final int footerPosition = commentsAdapter.getItemCount() - 1;
final boolean footerVisible = lastVisibleItemPosition == footerPosition;
final boolean replyCommentFocused = commentsAdapter.isReplyToCommentFocused();
final boolean fabShouldBeVisible =
((firstVisibleItemPosition == 0 && !enterCommentFocused) || !footerVisible)
&& !replyCommentFocused;
if (!fabShouldBeVisible && fabIsVisible) {
fabIsVisible = false;
fab.animate()
.scaleX(0f)
.scaleY(0f)
.alpha(0.6f)
.setDuration(200L)
.setInterpolator(getFastOutLinearInInterpolator(this))
.withLayer()
.setListener(postHideFab)
.start();
} else if (fabShouldBeVisible && !fabIsVisible) {
fabIsVisible = true;
fab.animate()
.scaleX(1f)
.scaleY(1f)
.alpha(1f)
.setDuration(200L)
.setInterpolator(getLinearOutSlowInInterpolator(this))
.withLayer()
.setListener(preShowFab)
.start();
ImeUtils.hideIme(enterComment);
}
}
private AnimatorListenerAdapter preShowFab = new AnimatorListenerAdapter() {
@Override
public void onAnimationStart(Animator animation) {
fab.setVisibility(View.VISIBLE);
}
};
private AnimatorListenerAdapter postHideFab = new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
fab.setVisibility(View.GONE);
}
};
// title can expand up to a max number of lines. If it does then adjust UI to reflect
private View.OnLayoutChangeListener titlebarLayout = new View.OnLayoutChangeListener() {
@Override
public void onLayoutChange(View v, int left, int top, int right, int bottom, int
oldLeft, int oldTop, int oldRight, int oldBottom) {
if ((bottom - top) != (oldBottom - oldTop)) {
commentsList.setPaddingRelative(commentsList.getPaddingStart(),
collapsingToolbar.getHeight(),
commentsList.getPaddingEnd(),
commentsList.getPaddingBottom());
commentsList.scrollToPosition(0);
}
collapsingToolbar.removeOnLayoutChangeListener(this);
}
};
private View.OnClickListener fabClick = new View.OnClickListener() {
@Override
public void onClick(View v) {
doFabExpand();
CustomTabActivityHelper.openCustomTab(
DesignerNewsStory.this,
getCustomTabIntent(DesignerNewsStory.this, story,
customTab.getSession())
.setStartAnimations(getApplicationContext(),
R.anim.chrome_custom_tab_enter,
R.anim.fade_out_rapidly)
.build(),
Uri.parse(story.url));
}
};
private void doFabExpand() {
// translate the chrome placeholder ui so that it is centered on the FAB
int fabCenterX = (fab.getLeft() + fab.getRight()) / 2;
int fabCenterY = ((fab.getTop() + fab.getBottom()) / 2) - fabExpand.getTop();
int translateX = fabCenterX - (fabExpand.getWidth() / 2);
int translateY = fabCenterY - (fabExpand.getHeight() / 2);
fabExpand.setTranslationX(translateX);
fabExpand.setTranslationY(translateY);
// then reveal the placeholder ui, starting from the center & same dimens as fab
fabExpand.setVisibility(View.VISIBLE);
Animator reveal = ViewAnimationUtils.createCircularReveal(
fabExpand,
fabExpand.getWidth() / 2,
fabExpand.getHeight() / 2,
fab.getWidth() / 2,
(int) Math.hypot(fabExpand.getWidth() / 2, fabExpand.getHeight() / 2))
.setDuration(fabExpandDuration);
// translate the placeholder ui back into position along an arc
GravityArcMotion arcMotion = new GravityArcMotion();
arcMotion.setMinimumVerticalAngle(70f);
Path motionPath = arcMotion.getPath(translateX, translateY, 0, 0);
Animator position = ObjectAnimator.ofFloat(fabExpand, View.TRANSLATION_X, View
.TRANSLATION_Y, motionPath)
.setDuration(fabExpandDuration);
// animate from the FAB colour to the placeholder background color
Animator background = ObjectAnimator.ofArgb(fabExpand,
ViewUtils.BACKGROUND_COLOR,
ContextCompat.getColor(this, R.color.designer_news),
ContextCompat.getColor(this, R.color.background_light))
.setDuration(fabExpandDuration);
// fade out the fab (rapidly)
Animator fadeOutFab = ObjectAnimator.ofFloat(fab, View.ALPHA, 0f)
.setDuration(60);
// play 'em all together with the material interpolator
AnimatorSet show = new AnimatorSet();
show.setInterpolator(getFastOutSlowInInterpolator(DesignerNewsStory.this));
show.playTogether(reveal, background, position, fadeOutFab);
show.start();
}
private void bindDescription() {
final TextView storyComment = (TextView) header.findViewById(R.id.story_comment);
if (!TextUtils.isEmpty(story.comment)) {
HtmlUtils.parseMarkdownAndSetText(storyComment, story.comment, markdown,
new Bypass.LoadImageCallback() {
@Override
public void loadImage(String src, ImageLoadingSpan loadingSpan) {
Glide.with(DesignerNewsStory.this)
.load(src)
.asBitmap()
.diskCacheStrategy(DiskCacheStrategy.ALL)
.into(new ImageSpanTarget(storyComment, loadingSpan));
}
});
} else {
storyComment.setVisibility(View.GONE);
}
upvoteStory = (TextView) header.findViewById(R.id.story_vote_action);
upvoteStory.setText(getResources().getQuantityString(R.plurals.upvotes, story.vote_count,
NumberFormat.getInstance().format(story.vote_count)));
upvoteStory.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(final View v) {
upvoteStory();
}
});
final TextView share = (TextView) header.findViewById(R.id.story_share_action);
share.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
((AnimatedVectorDrawable) share.getCompoundDrawables()[1]).start();
startActivity(ShareCompat.IntentBuilder.from(DesignerNewsStory.this)
.setText(story.url)
.setType("text/plain")
.setSubject(story.title)
.getIntent());
}
});
TextView storyPosterTime = (TextView) header.findViewById(R.id.story_poster_time);
SpannableString poster = new SpannableString(story.user_display_name.toLowerCase());
poster.setSpan(new TextAppearanceSpan(this, R.style.TextAppearance_CommentAuthor),
0, poster.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
CharSequence job =
!TextUtils.isEmpty(story.user_job) ? "\n" + story.user_job.toLowerCase() : "";
CharSequence timeAgo = DateUtils.getRelativeTimeSpanString(story.created_at.getTime(),
System.currentTimeMillis(),
DateUtils.SECOND_IN_MILLIS)
.toString().toLowerCase();
storyPosterTime.setText(TextUtils.concat(poster, job, "\n", timeAgo));
ImageView avatar = (ImageView) header.findViewById(R.id.story_poster_avatar);
if (!TextUtils.isEmpty(story.user_portrait_url)) {
Glide.with(this)
.load(story.user_portrait_url)
.placeholder(R.drawable.avatar_placeholder)
.transform(circleTransform)
.into(avatar);
} else {
avatar.setVisibility(View.GONE);
}
}
@NonNull
private View setupCommentField() {
View enterCommentView = getLayoutInflater()
.inflate(R.layout.designer_news_enter_comment, commentsList, false);
enterComment = (EditText) enterCommentView.findViewById(R.id.comment);
postComment = (ImageButton) enterCommentView.findViewById(R.id.post_comment);
postComment.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if (designerNewsPrefs.isLoggedIn()) {
if (TextUtils.isEmpty(enterComment.getText())) return;
enterComment.setEnabled(false);
postComment.setEnabled(false);
final Call<Comment> comment = designerNewsPrefs.getApi()
.comment(story.id, enterComment.getText().toString());
comment.enqueue(new Callback<Comment>() {
@Override
public void onResponse(Call<Comment> call, Response<Comment> response) {
enterComment.getText().clear();
enterComment.setEnabled(true);
postComment.setEnabled(true);
commentsAdapter.addComment(response.body());
}
@Override
public void onFailure(Call<Comment> call, Throwable t) {
Toast.makeText(getApplicationContext(),
"Failed to post comment :(", Toast.LENGTH_SHORT).show();
enterComment.setEnabled(true);
postComment.setEnabled(true);
}
});
} else {
needsLogin(postComment, 0);
}
enterComment.clearFocus();
}
});
enterComment.setOnFocusChangeListener(enterCommentFocus);
return enterCommentView;
}
private void upvoteStory() {
if (designerNewsPrefs.isLoggedIn()) {
if (!upvoteStory.isActivated()) {
upvoteStory.setActivated(true);
final Call<Story> upvoteStory
= designerNewsPrefs.getApi().upvoteStory(story.id);
upvoteStory.enqueue(new Callback<Story>() {
@Override
public void onResponse(Call<Story> call, Response<Story> response) {
final int newUpvoteCount = response.body().vote_count;
DesignerNewsStory.this.upvoteStory.setText(getResources().getQuantityString(
R.plurals.upvotes, newUpvoteCount,
NumberFormat.getInstance().format(newUpvoteCount)));
}
@Override
public void onFailure(Call<Story> call, Throwable t) { }
});
} else {
upvoteStory.setActivated(false);
// TODO delete upvote. Not available in v1 API.
}
} else {
needsLogin(upvoteStory, RC_LOGIN_UPVOTE);
}
}
private void needsLogin(View triggeringView, int requestCode) {
Intent login = new Intent(DesignerNewsStory.this,
DesignerNewsLogin.class);
MorphTransform.addExtras(login, ContextCompat.getColor(this, R.color.background_light),
triggeringView.getHeight() / 2);
ActivityOptions options = ActivityOptions.makeSceneTransitionAnimation(
DesignerNewsStory.this,
triggeringView, getString(R.string.transition_designer_news_login));
startActivityForResult(login, requestCode, options.toBundle());
}
private void unnestComments(List<Comment> nested, List<Comment> flat) {
for (Comment comment : nested) {
flat.add(comment);
if (comment.comments != null && comment.comments.size() > 0) {
unnestComments(comment.comments, flat);
}
}
}
private View.OnFocusChangeListener enterCommentFocus = new View.OnFocusChangeListener() {
@Override
public void onFocusChange(View view, boolean hasFocus) {
// kick off an anim (via animated state list) on the post button. see
// @drawable/ic_add_comment_state
postComment.setActivated(hasFocus);
updateFabVisibility();
}
};
private boolean isOP(Long userId) {
return userId.equals(story.user_id);
}
/* package */ class DesignerNewsCommentsAdapter
extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
private static final int TYPE_HEADER = 0;
private static final int TYPE_NO_COMMENTS = 1;
private static final int TYPE_COMMENT = 2;
private static final int TYPE_COMMENT_REPLY = 3;
private static final int TYPE_FOOTER = 4;
private View header;
private List<Comment> comments;
private View footer;
private int expandedCommentPosition = RecyclerView.NO_POSITION;
private boolean replyToCommentFocused = false;
DesignerNewsCommentsAdapter(@NonNull View header,
@NonNull List<Comment> comments,
@NonNull View footer) {
this.header = header;
this.comments = comments;
this.footer = footer;
}
@Override
public int getItemViewType(int position) {
if (position == 0) return TYPE_HEADER;
if (isCommentReplyExpanded() && position == expandedCommentPosition + 1)
return TYPE_COMMENT_REPLY;
int footerPosition = hasComments() ? 1 + comments.size() // header + comments
: 2; // header + no comments view
if (isCommentReplyExpanded()) footerPosition++;
if (position == footerPosition) return TYPE_FOOTER;
return hasComments() ? TYPE_COMMENT : TYPE_NO_COMMENTS;
}
@Override
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
switch (viewType) {
case TYPE_HEADER:
return new HeaderHolder(header);
case TYPE_COMMENT:
return createCommentHolder(parent);
case TYPE_COMMENT_REPLY:
return createCommentReplyHolder(parent);
case TYPE_NO_COMMENTS:
return new NoCommentsHolder(
getLayoutInflater().inflate(
R.layout.designer_news_no_comments, parent, false));
case TYPE_FOOTER:
return new FooterHolder(footer);
}
return null;
}
@Override
public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
switch (getItemViewType(position)) {
case TYPE_COMMENT:
bindComment((CommentHolder) holder, null);
break;
case TYPE_COMMENT_REPLY:
bindCommentReply((CommentReplyHolder) holder);
break;
} // nothing to bind for header / no comment / footer views
}
@Override
public void onBindViewHolder(RecyclerView.ViewHolder holder,
int position,
List<Object> partialChangePayloads) {
switch (getItemViewType(position)) {
case TYPE_COMMENT:
bindComment((CommentHolder) holder, partialChangePayloads);
break;
default:
onBindViewHolder(holder, position);
}
}
@Override
public int getItemCount() {
int itemCount = 2; // header + footer
if (hasComments()) {
itemCount += comments.size();
} else {
itemCount++; // no comments view
}
if (isCommentReplyExpanded()) itemCount++;
return itemCount;
}
public void addComment(Comment newComment) {
if (!hasComments()) {
notifyItemRemoved(1); // remove the no comments view
}
comments.add(newComment);
notifyItemInserted(commentIndexToAdapterPosition(comments.size() - 1));
}
/**
* Add a new comment and return the adapter position that it was inserted at.
*/
public int addCommentReply(Comment newComment, int inReplyToAdapterPosition) {
// when replying to a comment, we want to insert it after any existing replies
// i.e. after any following comments with the same or greater depth
int commentIndex = adapterPositionToCommentIndex(inReplyToAdapterPosition);
do {
commentIndex++;
} while (commentIndex < comments.size() &&
comments.get(commentIndex).depth >= newComment.depth);
comments.add(commentIndex, newComment);
int adapterPosition = commentIndexToAdapterPosition(commentIndex);
notifyItemInserted(adapterPosition);
return adapterPosition;
}
public boolean isReplyToCommentFocused() {
return replyToCommentFocused;
}
private boolean hasComments() {
return !comments.isEmpty();
}
private boolean isCommentReplyExpanded() {
return expandedCommentPosition != RecyclerView.NO_POSITION;
}
private Comment getComment(int adapterPosition) {
return comments.get(adapterPositionToCommentIndex(adapterPosition));
}
private int adapterPositionToCommentIndex(int adapterPosition) {
int index = adapterPosition - 1; // less header
if (isCommentReplyExpanded()
&& adapterPosition > expandedCommentPosition) index--;
return index;
}
private int commentIndexToAdapterPosition(int index) {
int adapterPosition = index + 1; // header
if (isCommentReplyExpanded()) {
int expandedCommentIndex = adapterPositionToCommentIndex(expandedCommentPosition);
if (index > expandedCommentIndex) adapterPosition++;
}
return adapterPosition;
}
@NonNull
private CommentHolder createCommentHolder(ViewGroup parent) {
final CommentHolder holder = new CommentHolder(
getLayoutInflater().inflate(R.layout.designer_news_comment, parent, false));
holder.itemView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
final boolean collapsingSelf =
expandedCommentPosition == holder.getAdapterPosition();
collapseExpandedComment();
if (collapsingSelf) return;
// show reply below this
expandedCommentPosition = holder.getAdapterPosition();
notifyItemInserted(expandedCommentPosition + 1);
notifyItemChanged(expandedCommentPosition, CommentAnimator.EXPAND_COMMENT);
}
});
holder.threadDepth.setImageDrawable(
new ThreadedCommentDrawable(threadWidth, threadGap));
return holder;
}
private void collapseExpandedComment() {
if (!isCommentReplyExpanded()) return;
notifyItemChanged(expandedCommentPosition, CommentAnimator.COLLAPSE_COMMENT);
notifyItemRemoved(expandedCommentPosition + 1);
replyToCommentFocused = false;
expandedCommentPosition = RecyclerView.NO_POSITION;
updateFabVisibility();
}
private void bindComment(final CommentHolder holder, List<Object> partialChanges) {
// Check if this is a partial update for expanding/collapsing a comment. If it is we
// can do a partial bind as the bound data has not changed.
if (partialChanges == null || partialChanges.isEmpty() ||
!(partialChanges.contains(CommentAnimator.COLLAPSE_COMMENT)
|| partialChanges.contains(CommentAnimator.EXPAND_COMMENT))) {
final Comment comment = getComment(holder.getAdapterPosition());
HtmlUtils.parseMarkdownAndSetText(holder.comment, comment.body, markdown,
new Bypass.LoadImageCallback() {
@Override
public void loadImage(String src, ImageLoadingSpan loadingSpan) {
Glide.with(DesignerNewsStory.this)
.load(src)
.asBitmap()
.diskCacheStrategy(DiskCacheStrategy.ALL)
.into(new ImageSpanTarget(holder.comment, loadingSpan));
}
});
if (comment.user_display_name != null) {
holder.author.setText(comment.user_display_name.toLowerCase());
}
holder.author.setOriginalPoster(isOP(comment.user_id));
if (comment.created_at != null) {
holder.timeAgo.setText(
DateUtils.getRelativeTimeSpanString(comment.created_at.getTime(),
System.currentTimeMillis(),
DateUtils.SECOND_IN_MILLIS)
.toString().toLowerCase());
}
// FIXME updating drawable doesn't seem to be working, just create a new one
//((ThreadedCommentDrawable) holder.threadDepth.getDrawable())
// .setDepth(comment.depth);
holder.threadDepth.setImageDrawable(
new ThreadedCommentDrawable(threadWidth, threadGap, comment.depth));
}
// set/clear expanded comment state
holder.itemView.setActivated(holder.getAdapterPosition() == expandedCommentPosition);
if (holder.getAdapterPosition() == expandedCommentPosition) {
final int threadDepthWidth = holder.threadDepth.getDrawable().getIntrinsicWidth();
final float leftShift = -(threadDepthWidth + ((ViewGroup.MarginLayoutParams)
holder.threadDepth.getLayoutParams()).getMarginEnd());
holder.author.setTranslationX(leftShift);
holder.comment.setTranslationX(leftShift);
holder.threadDepth.setTranslationX(-(threadDepthWidth
+ ((ViewGroup.MarginLayoutParams)
holder.threadDepth.getLayoutParams()).getMarginStart()));
} else {
holder.threadDepth.setTranslationX(0f);
holder.author.setTranslationX(0f);
holder.comment.setTranslationX(0f);
}
}
@NonNull
private CommentReplyHolder createCommentReplyHolder(ViewGroup parent) {
final CommentReplyHolder holder = new CommentReplyHolder(getLayoutInflater()
.inflate(R.layout.designer_news_comment_actions, parent, false));
holder.commentVotes.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if (designerNewsPrefs.isLoggedIn()) {
Comment comment = getComment(holder.getAdapterPosition());
if (!holder.commentVotes.isActivated()) {
final Call<Comment> upvoteComment =
designerNewsPrefs.getApi().upvoteComment(comment.id);
upvoteComment.enqueue(new Callback<Comment>() {
@Override
public void onResponse(Call<Comment> call,
Response<Comment> response) { }
@Override
public void onFailure(Call<Comment> call, Throwable t) { }
});
comment.upvoted = true;
comment.vote_count++;
holder.commentVotes.setText(String.valueOf(comment.vote_count));
holder.commentVotes.setActivated(true);
} else {
comment.upvoted = false;
comment.vote_count--;
holder.commentVotes.setText(String.valueOf(comment.vote_count));
holder.commentVotes.setActivated(false);
// TODO actually delete upvote
}
} else {
needsLogin(holder.commentVotes, 0);
}
holder.commentReply.clearFocus();
}
});
holder.postReply.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if (designerNewsPrefs.isLoggedIn()) {
if (TextUtils.isEmpty(holder.commentReply.getText())) return;
final int inReplyToCommentPosition = holder.getAdapterPosition() - 1;
final Comment replyingTo = getComment(inReplyToCommentPosition);
collapseExpandedComment();
// insert a locally created comment before actually
// hitting the API for immediate response
int replyDepth = replyingTo.depth + 1;
final int newReplyPosition = commentsAdapter.addCommentReply(
new Comment.Builder()
.setBody(holder.commentReply.getText().toString())
.setCreatedAt(new Date())
.setDepth(replyDepth)
.setUserId(designerNewsPrefs.getUserId())
.setUserDisplayName(designerNewsPrefs.getUserName())
.setUserPortraitUrl(designerNewsPrefs.getUserAvatar())
.build(),
inReplyToCommentPosition);
final Call<Comment> replyToComment = designerNewsPrefs.getApi()
.replyToComment(replyingTo.id,
holder.commentReply.getText().toString());
replyToComment.enqueue(new Callback<Comment>() {
@Override
public void onResponse(Call<Comment> call, Response<Comment> response) {
}
@Override
public void onFailure(Call<Comment> call, Throwable t) {
Toast.makeText(getApplicationContext(),
"Failed to post comment :(", Toast.LENGTH_SHORT).show();
}
});
holder.commentReply.getText().clear();
ImeUtils.hideIme(holder.commentReply);
commentsList.scrollToPosition(newReplyPosition);
} else {
needsLogin(holder.postReply, 0);
}
holder.commentReply.clearFocus();
}
});
holder.commentReply.setOnFocusChangeListener(new View.OnFocusChangeListener() {
@Override
public void onFocusChange(View v, boolean hasFocus) {
replyToCommentFocused = hasFocus;
final Interpolator interp = getFastOutSlowInInterpolator(holder
.itemView.getContext());
if (hasFocus) {
holder.commentVotes.animate()
.translationX(-holder.commentVotes.getWidth())
.alpha(0f)
.setDuration(200L)
.setInterpolator(interp);
holder.replyLabel.animate()
.translationX(-holder.commentVotes.getWidth())
.setDuration(200L)
.setInterpolator(interp);
holder.postReply.setVisibility(View.VISIBLE);
holder.postReply.setAlpha(0f);
holder.postReply.animate()
.alpha(1f)
.setDuration(200L)
.setInterpolator(interp)
.setListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationStart(Animator animation) {
holder.itemView.setHasTransientState(true);
}
@Override
public void onAnimationEnd(Animator animation) {
holder.itemView.setHasTransientState(false);
}
});
updateFabVisibility();
} else {
holder.commentVotes.animate()
.translationX(0f)
.alpha(1f)
.setDuration(200L)
.setInterpolator(interp);
holder.replyLabel.animate()
.translationX(0f)
.setDuration(200L)
.setInterpolator(interp);
holder.postReply.animate()
.alpha(0f)
.setDuration(200L)
.setInterpolator(interp)
.setListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationStart(Animator animation) {
holder.itemView.setHasTransientState(true);
}
@Override
public void onAnimationEnd(Animator animation) {
holder.postReply.setVisibility(View.INVISIBLE);
holder.itemView.setHasTransientState(true);
}
});
updateFabVisibility();
}
holder.postReply.setActivated(hasFocus);
}
});
return holder;
}
private void bindCommentReply(CommentReplyHolder holder) {
Comment comment = getComment(holder.getAdapterPosition() - 1);
holder.commentVotes.setText(String.valueOf(comment.vote_count));
holder.commentVotes.setActivated(comment.upvoted != null && comment.upvoted);
}
}
/* package */ static class CommentHolder extends RecyclerView.ViewHolder {
@BindView(R.id.depth) ImageView threadDepth;
@BindView(R.id.comment_author) AuthorTextView author;
@BindView(R.id.comment_time_ago) TextView timeAgo;
@BindView(R.id.comment_text) TextView comment;
public CommentHolder(View itemView) {
super(itemView);
ButterKnife.bind(this, itemView);
}
}
/* package */ static class CommentReplyHolder extends RecyclerView.ViewHolder {
@BindView(R.id.comment_votes) Button commentVotes;
@BindView(R.id.comment_reply_label) TextInputLayout replyLabel;
@BindView(R.id.comment_reply) EditText commentReply;
@BindView(R.id.post_reply) ImageButton postReply;
public CommentReplyHolder(View itemView) {
super(itemView);
ButterKnife.bind(this, itemView);
}
}
/* package */ static class HeaderHolder extends RecyclerView.ViewHolder {
public HeaderHolder(View itemView) {
super(itemView);
}
}
/* package */ static class NoCommentsHolder extends RecyclerView.ViewHolder {
public NoCommentsHolder(View itemView) {
super(itemView);
}
}
/* package */ static class FooterHolder extends RecyclerView.ViewHolder {
public FooterHolder(View itemView) {
super(itemView);
}
}
private static class CommentAnimator extends SlideInItemAnimator {
CommentAnimator(long addRemoveDuration) {
super();
setAddDuration(addRemoveDuration);
setRemoveDuration(addRemoveDuration);
}
public static final int EXPAND_COMMENT = 1;
public static final int COLLAPSE_COMMENT = 2;
@Override
public boolean canReuseUpdatedViewHolder(RecyclerView.ViewHolder viewHolder) {
return true;
}
@NonNull
@Override
public ItemHolderInfo recordPreLayoutInformation(RecyclerView.State state,
RecyclerView.ViewHolder viewHolder,
int changeFlags,
List<Object> payloads) {
CommentItemHolderInfo info = (CommentItemHolderInfo)
super.recordPreLayoutInformation(state, viewHolder, changeFlags, payloads);
info.doExpand = payloads.contains(EXPAND_COMMENT);
info.doCollapse = payloads.contains(COLLAPSE_COMMENT);
return info;
}
@Override
public boolean animateChange(RecyclerView.ViewHolder oldHolder,
RecyclerView.ViewHolder newHolder,
ItemHolderInfo preInfo,
ItemHolderInfo postInfo) {
if (newHolder instanceof CommentHolder && preInfo instanceof CommentItemHolderInfo) {
final CommentHolder holder = (CommentHolder) newHolder;
final CommentItemHolderInfo info = (CommentItemHolderInfo) preInfo;
final float expandedThreadOffset = -(holder.threadDepth.getWidth() + ((ViewGroup
.MarginLayoutParams) holder.threadDepth.getLayoutParams())
.getMarginStart());
final float expandedAuthorCommentOffset = -(holder.threadDepth.getWidth() +
((ViewGroup.MarginLayoutParams) holder.threadDepth.getLayoutParams())
.getMarginEnd());
if (info.doExpand) {
Interpolator moveInterpolator = getFastOutSlowInInterpolator(holder
.itemView.getContext());
holder.threadDepth.setTranslationX(0f);
holder.threadDepth.animate()
.translationX(expandedThreadOffset)
.setDuration(160L)
.setInterpolator(moveInterpolator);
holder.author.setTranslationX(0f);
holder.author.animate()
.translationX(expandedAuthorCommentOffset)
.setDuration(320L)
.setInterpolator(moveInterpolator);
holder.comment.setTranslationX(0f);
holder.comment.animate()
.translationX(expandedAuthorCommentOffset)
.setDuration(320L)
.setInterpolator(moveInterpolator)
.setListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationStart(Animator animation) {
dispatchChangeStarting(holder, false);
holder.itemView.setHasTransientState(true);
}
@Override
public void onAnimationEnd(Animator animation) {
holder.itemView.setHasTransientState(false);
dispatchChangeFinished(holder, false);
}
});
} else if (info.doCollapse) {
Interpolator enterInterpolator = getLinearOutSlowInInterpolator
(holder.itemView
.getContext());
Interpolator moveInterpolator = getFastOutSlowInInterpolator(holder
.itemView
.getContext());
// return the thread depth indicator into place
holder.threadDepth.setTranslationX(expandedThreadOffset);
holder.threadDepth.animate()
.translationX(0f)
.setDuration(200L)
.setInterpolator(enterInterpolator)
.setListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationStart(Animator animation) {
dispatchChangeStarting(holder, false);
holder.itemView.setHasTransientState(true);
}
@Override
public void onAnimationEnd(Animator animation) {
holder.itemView.setHasTransientState(false);
dispatchChangeFinished(holder, false);
}
});
// return the text into place
holder.author.setTranslationX(expandedAuthorCommentOffset);
holder.author.animate()
.translationX(0f)
.setDuration(200L)
.setInterpolator(moveInterpolator);
holder.comment.setTranslationX(expandedAuthorCommentOffset);
holder.comment.animate()
.translationX(0f)
.setDuration(200L)
.setInterpolator(moveInterpolator);
}
}
return super.animateChange(oldHolder, newHolder, preInfo, postInfo);
}
@Override
public ItemHolderInfo obtainHolderInfo() {
return new CommentItemHolderInfo();
}
/* package */ static class CommentItemHolderInfo extends ItemHolderInfo {
boolean doExpand;
boolean doCollapse;
}
}
}