/*
* Copyright (c) 2015 PocketHub
*
* 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 com.github.pockethub.android.ui.issue;
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.text.TextUtils;
import android.util.Log;
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 android.widget.ImageView;
import android.widget.LinearLayout.LayoutParams;
import android.widget.ListView;
import android.widget.ProgressBar;
import android.widget.TextView;
import com.github.pockethub.android.core.issue.FullIssue;
import com.meisolsson.githubsdk.core.ServiceGenerator;
import com.meisolsson.githubsdk.model.GitHubComment;
import com.meisolsson.githubsdk.model.GitHubEvent;
import com.meisolsson.githubsdk.model.Issue;
import com.meisolsson.githubsdk.model.IssueEvent;
import com.meisolsson.githubsdk.model.IssueState;
import com.meisolsson.githubsdk.model.Label;
import com.meisolsson.githubsdk.model.Milestone;
import com.meisolsson.githubsdk.model.PullRequest;
import com.meisolsson.githubsdk.model.Repository;
import com.meisolsson.githubsdk.model.User;
import com.github.pockethub.android.R;
import com.github.pockethub.android.accounts.AccountUtils;
import com.github.pockethub.android.core.issue.IssueStore;
import com.github.pockethub.android.core.issue.IssueUtils;
import com.github.pockethub.android.core.issue.RefreshIssueTask;
import com.github.pockethub.android.rx.ProgressObserverAdapter;
import com.github.pockethub.android.ui.ConfirmDialogFragment;
import com.github.pockethub.android.ui.DialogFragment;
import com.github.pockethub.android.ui.BaseActivity;
import com.github.pockethub.android.ui.HeaderFooterListAdapter;
import com.github.pockethub.android.ui.SelectableLinkMovementMethod;
import com.github.pockethub.android.ui.StyledText;
import com.github.pockethub.android.ui.comment.CommentListAdapter;
import com.github.pockethub.android.ui.comment.DeleteCommentListener;
import com.github.pockethub.android.ui.comment.EditCommentListener;
import com.github.pockethub.android.ui.commit.CommitCompareViewActivity;
import com.github.pockethub.android.util.AvatarLoader;
import com.github.pockethub.android.util.HttpImageGetter;
import com.github.pockethub.android.util.InfoUtils;
import com.github.pockethub.android.util.ShareUtils;
import com.github.pockethub.android.util.ToastUtils;
import com.meisolsson.githubsdk.service.issues.IssueCommentService;
import com.google.inject.Inject;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import io.reactivex.Single;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.schedulers.Schedulers;
import retrofit2.Response;
import static android.app.Activity.RESULT_OK;
import static android.view.View.GONE;
import static android.view.View.VISIBLE;
import static com.github.pockethub.android.Intents.EXTRA_CAN_WRITE_REPO;
import static com.github.pockethub.android.Intents.EXTRA_COMMENT;
import static com.github.pockethub.android.Intents.EXTRA_ISSUE;
import static com.github.pockethub.android.Intents.EXTRA_ISSUE_NUMBER;
import static com.github.pockethub.android.Intents.EXTRA_REPOSITORY_NAME;
import static com.github.pockethub.android.Intents.EXTRA_REPOSITORY_OWNER;
import static com.github.pockethub.android.Intents.EXTRA_USER;
import static com.github.pockethub.android.RequestCodes.COMMENT_CREATE;
import static com.github.pockethub.android.RequestCodes.COMMENT_DELETE;
import static com.github.pockethub.android.RequestCodes.COMMENT_EDIT;
import static com.github.pockethub.android.RequestCodes.ISSUE_ASSIGNEE_UPDATE;
import static com.github.pockethub.android.RequestCodes.ISSUE_CLOSE;
import static com.github.pockethub.android.RequestCodes.ISSUE_EDIT;
import static com.github.pockethub.android.RequestCodes.ISSUE_LABELS_UPDATE;
import static com.github.pockethub.android.RequestCodes.ISSUE_MILESTONE_UPDATE;
import static com.github.pockethub.android.RequestCodes.ISSUE_REOPEN;
import static com.github.pockethub.android.ui.view.OcticonTextView.ICON_COMMIT;
/**
* Fragment to display an issue
*/
public class IssueFragment extends DialogFragment {
private static final String TAG = "IssueFragment";
private int issueNumber;
private List<Object> items;
private Repository repositoryId;
private Issue issue;
private User user;
private boolean canWrite;
@Inject
private AvatarLoader avatars;
@Inject
private IssueStore store;
private ListView list;
private ProgressBar progress;
private View headerView;
private View loadingView;
private View footerView;
private HeaderFooterListAdapter<CommentListAdapter> adapter;
private EditMilestoneTask milestoneTask;
private EditAssigneeTask assigneeTask;
private EditLabelsTask labelsTask;
private EditStateTask stateTask;
private TextView stateText;
private TextView titleText;
private TextView bodyText;
private TextView authorText;
private TextView createdDateText;
private ImageView creatorAvatar;
private ViewGroup commitsView;
private TextView assigneeText;
private ImageView assigneeAvatar;
private TextView labelsArea;
private View milestoneArea;
private View milestoneProgressArea;
private TextView milestoneText;
private MenuItem stateItem;
@Inject
private HttpImageGetter bodyImageGetter;
@Inject
private HttpImageGetter commentImageGetter;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Bundle args = getArguments();
repositoryId = InfoUtils.createRepoFromData(
args.getString(EXTRA_REPOSITORY_OWNER),
args.getString(EXTRA_REPOSITORY_NAME));
issueNumber = args.getInt(EXTRA_ISSUE_NUMBER);
user = args.getParcelable(EXTRA_USER);
canWrite = args.getBoolean(EXTRA_CAN_WRITE_REPO, false);
BaseActivity dialogActivity = (BaseActivity) getActivity();
ProgressObserverAdapter<Issue> observer = new ProgressObserverAdapter<Issue>(getActivity()) {
@Override
public void onNext(Issue issue) {
super.onNext(issue);
updateHeader(issue);
refreshIssue();
}
};
milestoneTask = new EditMilestoneTask(dialogActivity, repositoryId, issueNumber, observer);
assigneeTask = new EditAssigneeTask(dialogActivity, repositoryId, issueNumber, observer);
labelsTask = new EditLabelsTask(dialogActivity, repositoryId, issueNumber, observer);
stateTask = new EditStateTask(dialogActivity, repositoryId, issueNumber, observer);
}
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
adapter.addHeader(headerView);
adapter.addFooter(footerView);
issue = store.getIssue(repositoryId, issueNumber);
TextView loadingText = (TextView) loadingView
.findViewById(R.id.tv_loading);
loadingText.setText(R.string.loading_comments);
if (issue == null || (issue.comments() > 0 && items == null)) {
adapter.addHeader(loadingView);
}
if (issue != null && items != null) {
updateList(issue, items);
} else {
if (issue != null) {
updateHeader(issue);
}
refreshIssue();
}
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_comment_list, container, false);
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
list = (ListView) view.findViewById(android.R.id.list);
progress = (ProgressBar) view.findViewById(R.id.pb_loading);
LayoutInflater inflater = getLayoutInflater(savedInstanceState);
headerView = inflater.inflate(R.layout.issue_header, null);
stateText = (TextView) headerView.findViewById(R.id.tv_state);
titleText = (TextView) headerView.findViewById(R.id.tv_issue_title);
authorText = (TextView) headerView.findViewById(R.id.tv_issue_author);
createdDateText = (TextView) headerView
.findViewById(R.id.tv_issue_creation_date);
creatorAvatar = (ImageView) headerView.findViewById(R.id.iv_avatar);
commitsView = (ViewGroup) headerView.findViewById(R.id.ll_issue_commits);
assigneeText = (TextView) headerView.findViewById(R.id.tv_assignee_name);
assigneeAvatar = (ImageView) headerView
.findViewById(R.id.iv_assignee_avatar);
labelsArea = (TextView) headerView.findViewById(R.id.tv_labels);
milestoneArea = headerView.findViewById(R.id.ll_milestone);
milestoneText = (TextView) headerView.findViewById(R.id.tv_milestone);
milestoneProgressArea = headerView.findViewById(R.id.v_closed);
bodyText = (TextView) headerView.findViewById(R.id.tv_issue_body);
bodyText.setMovementMethod(SelectableLinkMovementMethod.getInstance());
loadingView = inflater.inflate(R.layout.loading_item, null);
footerView = inflater.inflate(R.layout.footer_separator, null);
commitsView.setOnClickListener(v -> {
if (IssueUtils.isPullRequest(issue)) {
openPullRequestCommits();
}
});
stateText.setOnClickListener(v -> {
if (issue != null) {
stateTask.confirm(IssueState.open.equals(issue.state()));
}
});
milestoneArea.setOnClickListener(v -> {
if (issue != null && canWrite) {
milestoneTask.prompt(issue.milestone());
}
});
headerView.findViewById(R.id.ll_assignee).setOnClickListener(v -> {
if (issue != null && canWrite) {
assigneeTask.prompt(issue.assignee());
}
});
labelsArea.setOnClickListener(v -> {
if (issue != null && canWrite) {
labelsTask.prompt(issue.labels());
}
});
Activity activity = getActivity();
String userName = AccountUtils.getLogin(activity);
adapter = new HeaderFooterListAdapter<>(list,
new CommentListAdapter(activity.getLayoutInflater(), null, avatars,
commentImageGetter, editCommentListener, deleteCommentListener, userName, canWrite, issue));
list.setAdapter(adapter);
}
private void updateHeader(final Issue issue) {
if (!isUsable()) {
return;
}
titleText.setText(issue.title());
String body = issue.bodyHtml();
if (!TextUtils.isEmpty(body)) {
bodyImageGetter.bind(bodyText, body, issue.id());
} else {
bodyText.setText(R.string.no_description_given);
}
authorText.setText(issue.user().login());
createdDateText.setText(new StyledText().append(
getString(R.string.prefix_opened)).append(issue.createdAt()));
avatars.bind(creatorAvatar, issue.user());
if (IssueUtils.isPullRequest(issue) && issue.pullRequest().commits() != null
&& issue.pullRequest().commits() > 0) {
commitsView.setVisibility(VISIBLE);
TextView icon = (TextView) headerView.findViewById(R.id.tv_commit_icon);
icon.setText(ICON_COMMIT);
String commits = getString(R.string.pull_request_commits,
issue.pullRequest().commits());
((TextView) headerView.findViewById(R.id.tv_pull_request_commits)).setText(commits);
} else {
commitsView.setVisibility(GONE);
}
boolean open = IssueState.open.equals(issue.state());
if (!open) {
StyledText text = new StyledText();
text.bold(getString(R.string.closed));
Date closedAt = issue.closedAt();
if (closedAt != null) {
text.append(' ').append(closedAt);
}
stateText.setText(text);
stateText.setVisibility(VISIBLE);
} else {
stateText.setVisibility(GONE);
}
User assignee = issue.assignee();
if (assignee != null) {
StyledText name = new StyledText();
name.bold(assignee.login());
name.append(' ').append(getString(R.string.assigned));
assigneeText.setText(name);
assigneeAvatar.setVisibility(VISIBLE);
avatars.bind(assigneeAvatar, assignee);
} else {
assigneeAvatar.setVisibility(GONE);
assigneeText.setText(R.string.unassigned);
}
List<Label> labels = issue.labels();
if (labels != null && !labels.isEmpty()) {
LabelDrawableSpan.setText(labelsArea, labels);
labelsArea.setVisibility(VISIBLE);
} else {
labelsArea.setVisibility(GONE);
}
if (issue.milestone() != null) {
Milestone milestone = issue.milestone();
StyledText milestoneLabel = new StyledText();
milestoneLabel.append(getString(R.string.milestone_prefix));
milestoneLabel.append(' ');
milestoneLabel.bold(milestone.title());
milestoneText.setText(milestoneLabel);
float closed = milestone.closedIssues();
float total = closed + milestone.openIssues();
if (total > 0) {
((LayoutParams) milestoneProgressArea.getLayoutParams()).weight = closed
/ total;
milestoneProgressArea.setVisibility(VISIBLE);
} else {
milestoneProgressArea.setVisibility(GONE);
}
milestoneArea.setVisibility(VISIBLE);
} else {
milestoneArea.setVisibility(GONE);
}
progress.setVisibility(GONE);
list.setVisibility(VISIBLE);
updateStateItem(issue);
}
private void refreshIssue() {
Single.create(new RefreshIssueTask(getActivity(), repositoryId, issueNumber, bodyImageGetter, commentImageGetter))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.filter(fullIssue -> isUsable())
.compose(this.bindToLifecycle())
.subscribe(fullIssue -> {
issue = fullIssue.getIssue();
items = new ArrayList<>();
items.addAll(fullIssue.getEvents());
items.addAll(fullIssue.getComments());
updateList(fullIssue.getIssue(), items);
}, e -> {
ToastUtils.show(getActivity(), e, R.string.error_issue_load);
progress.setVisibility(GONE);
});
}
private void updateList(Issue issue, List<Object> items) {
Collections.sort(items, new Comparator<Object>() {
@Override
public int compare(Object lhs, Object rhs) {
Date l = getDate(lhs);
Date r = getDate(rhs);
if(l == null && r != null) {
return 1;
} else if(l != null && r == null) {
return -1;
} else if(l == null && r == null) {
return 0;
} else {
return l.compareTo(r);
}
}
private Date getDate(Object obj){
if(obj instanceof GitHubComment) {
return ((GitHubComment) obj).createdAt();
} else if(obj instanceof GitHubEvent) {
return ((GitHubEvent) obj).createdAt();
} else if(obj instanceof IssueEvent) {
return ((IssueEvent) obj).createdAt();
}
return null;
}
});
adapter.getWrappedAdapter().setItems(items);
adapter.removeHeader(loadingView);
adapter.getWrappedAdapter().setIssue(issue);
adapter.getWrappedAdapter().notifyDataSetChanged();
headerView.setVisibility(VISIBLE);
updateHeader(issue);
}
@Override
public void onDialogResult(int requestCode, int resultCode, Bundle arguments) {
if (RESULT_OK != resultCode) {
return;
}
switch (requestCode) {
case ISSUE_MILESTONE_UPDATE:
milestoneTask.edit(MilestoneDialogFragment.getSelected(arguments));
break;
case ISSUE_ASSIGNEE_UPDATE:
assigneeTask.edit(AssigneeDialogFragment.getSelected(arguments));
break;
case ISSUE_LABELS_UPDATE:
ArrayList<Label> labels = LabelsDialogFragment
.getSelected(arguments);
if (labels != null && !labels.isEmpty()) {
labelsTask.edit(labels.toArray(new Label[labels.size()]));
} else {
labelsTask.edit(null);
}
break;
case ISSUE_CLOSE:
stateTask.edit(true);
break;
case ISSUE_REOPEN:
stateTask.edit(false);
break;
case COMMENT_DELETE:
final GitHubComment comment = arguments.getParcelable(EXTRA_COMMENT);
ServiceGenerator.createService(getActivity(), IssueCommentService.class)
.deleteIssueComment(repositoryId.owner().login(), repositoryId.name(), comment.id())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.compose(this.bindToLifecycle())
.subscribe(new ProgressObserverAdapter<Response<Boolean>>(getActivity(), R.string.deleting_comment) {
@Override
public void onSuccess(Response<Boolean> response) {
super.onSuccess(response);
if (items != null) {
int commentPosition = findCommentPositionInItems(comment);
if (commentPosition >= 0) {
issue = issue.toBuilder().comments(issue.comments() - 1).build();
items.remove(commentPosition);
updateList(issue, items);
}
} else {
refreshIssue();
}
}
@Override
public void onError(Throwable e) {
super.onError(e);
Log.d(TAG, "Exception deleting comment on issue", e);
ToastUtils.show(getActivity(), e.getMessage());
}
});
break;
}
}
private void updateStateItem(Issue issue) {
if (issue != null && stateItem != null) {
if (IssueState.open.equals(issue.state())) {
stateItem.setTitle(R.string.close);
stateItem.setIcon(R.drawable.ic_github_issue_closed_white_24dp);
} else {
stateItem.setTitle(R.string.reopen);
stateItem.setIcon(R.drawable.ic_github_issue_reopened_white_24dp);
}
}
}
@Override
public void onPrepareOptionsMenu(Menu menu) {
super.onPrepareOptionsMenu(menu);
MenuItem editItem = menu.findItem(R.id.m_edit);
MenuItem stateItem = menu.findItem(R.id.m_state);
if (editItem != null && stateItem != null) {
boolean isCreator = false;
if(issue != null) {
isCreator = issue.user().login().equals(AccountUtils.getLogin(getActivity()));
}
editItem.setVisible(canWrite || isCreator);
stateItem.setVisible(canWrite || isCreator);
}
updateStateItem(issue);
}
@Override
public void onCreateOptionsMenu(Menu optionsMenu, MenuInflater inflater) {
inflater.inflate(R.menu.fragment_issue_view, optionsMenu);
stateItem = optionsMenu.findItem(R.id.m_state);
updateStateItem(issue);
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
if (RESULT_OK != resultCode || data == null) {
return;
}
switch (requestCode) {
case ISSUE_EDIT:
Issue editedIssue = data.getParcelableExtra(EXTRA_ISSUE);
bodyImageGetter.encode(editedIssue.id(), editedIssue.bodyHtml());
updateHeader(editedIssue);
return;
case COMMENT_CREATE:
GitHubComment comment = data
.getParcelableExtra(EXTRA_COMMENT);
if (items != null) {
items.add(comment);
issue = issue.toBuilder().comments(issue.comments() + 1).build();
updateList(issue, items);
} else {
refreshIssue();
}
return;
case COMMENT_EDIT:
comment = data
.getParcelableExtra(EXTRA_COMMENT);
if (items != null && comment != null) {
int commentPosition = findCommentPositionInItems(comment);
if (commentPosition >= 0) {
commentImageGetter.removeFromCache(comment.id());
replaceCommentInItems(commentPosition, comment);
updateList(issue, items);
}
} else {
refreshIssue();
}
}
}
private void shareIssue() {
String id = InfoUtils.createRepoId(repositoryId);
if (IssueUtils.isPullRequest(issue)) {
startActivity(ShareUtils.create("Pull Request " + issueNumber
+ " on " + id, "https://github.com/" + id + "/pull/"
+ issueNumber));
} else {
startActivity(ShareUtils
.create("Issue " + issueNumber + " on " + id,
"https://github.com/" + id + "/issues/"
+ issueNumber));
}
}
private void openPullRequestCommits() {
if (IssueUtils.isPullRequest(issue)) {
PullRequest pullRequest = issue.pullRequest();
String base = pullRequest.base().sha();
String head = pullRequest.head().sha();
Repository repo = pullRequest.base().repo();
startActivity(CommitCompareViewActivity.createIntent(repo, base, head));
}
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case R.id.m_edit:
if (issue != null) {
startActivityForResult(EditIssueActivity.createIntent(issue,
repositoryId.owner().login(), repositoryId.name(), user),
ISSUE_EDIT);
}
return true;
case R.id.m_comment:
if (issue != null) {
startActivityForResult(CreateCommentActivity.createIntent(
repositoryId, issueNumber, user), COMMENT_CREATE);
}
return true;
case R.id.m_refresh:
refreshIssue();
return true;
case R.id.m_share:
if (issue != null) {
shareIssue();
}
return true;
case R.id.m_state:
if (issue != null) {
stateTask.confirm(IssueState.open.equals(issue.state()));
}
return true;
default:
return super.onOptionsItemSelected(item);
}
}
/**
* Edit existing comment
*/
final EditCommentListener editCommentListener = new EditCommentListener() {
@Override
public void onEditComment(GitHubComment comment) {
startActivityForResult(EditCommentActivity.createIntent(
repositoryId, issueNumber, comment, user), COMMENT_EDIT);
}
};
/**
* Delete existing comment
*/
final DeleteCommentListener deleteCommentListener = comment -> {
Bundle args = new Bundle();
args.putParcelable(EXTRA_COMMENT, comment);
ConfirmDialogFragment.show(
getActivity(),
COMMENT_DELETE,
getActivity()
.getString(R.string.confirm_comment_delete_title),
getActivity().getString(
R.string.confirm_comment_delete_message), args);
};
/**
* Finds the position of the given comment in the list of this issue's items.
*
* @param comment The comment to look for.
* @return The position of the comment in the list, or -1 if not found.
*/
private int findCommentPositionInItems(@NonNull GitHubComment comment) {
int commentPosition = -1;
Object currentItem = null;
for (int currentPosition = 0; currentPosition < items.size(); currentPosition++) {
currentItem = items.get(currentPosition);
if (currentItem instanceof GitHubComment &&
comment.id() == ((GitHubComment) currentItem).id()) {
commentPosition = currentPosition;
break;
}
}
return commentPosition;
}
/**
* Replaces a comment in the list by another
*
* @param commentPosition The position of the comment in the list
* @param comment The comment to replace
* @return True if successfully removed, false otherwise.
*/
private boolean replaceCommentInItems(int commentPosition, @NonNull GitHubComment comment) {
Object item = items.get(commentPosition);
boolean result = false;
if (item instanceof GitHubComment) {
items.set(commentPosition, comment);
result = true;
}
return result;
}
}