Jumping to top capability and a progress/retry footer added to timelines.

This commit is contained in:
Vavassor 2017-01-18 13:35:07 -05:00
parent 6b684bceff
commit 2106d7a53c
9 changed files with 300 additions and 74 deletions

View File

@ -4,6 +4,7 @@ import android.Manifest;
import android.app.ProgressDialog;
import android.content.ContentResolver;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
@ -55,6 +56,7 @@ import java.io.InputStream;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Random;
@ -196,22 +198,12 @@ public class ComposeActivity extends AppCompatActivity {
VolleySingleton.getInstance(this).addToRequestQueue(request);
}
private void onReadyFailure(Exception exception, final String content,
final String visibility, final boolean sensitive) {
doErrorDialog(R.string.error_media_upload_sending, R.string.action_retry,
new View.OnClickListener() {
@Override
public void onClick(View v) {
readyStatus(content, visibility, sensitive);
}
});
}
private void readyStatus(final String content, final String visibility,
final boolean sensitive) {
final ProgressDialog dialog = ProgressDialog.show(this, "Finishing Media Upload",
"Uploading...", true);
new AsyncTask<Void, Void, Boolean>() {
"Uploading...", true, true);
final AsyncTask<Void, Void, Boolean> waitForMediaTask =
new AsyncTask<Void, Void, Boolean>() {
private Exception exception;
@Override
@ -235,7 +227,34 @@ public class ComposeActivity extends AppCompatActivity {
onReadyFailure(exception, content, visibility, sensitive);
}
}
}.execute();
@Override
protected void onCancelled() {
removeAllMediaFromQueue();
super.onCancelled();
}
};
dialog.setOnCancelListener(new DialogInterface.OnCancelListener() {
@Override
public void onCancel(DialogInterface dialog) {
/* Generating an interrupt by passing true here is important because an interrupt
* exception is the only thing that will kick the latch out of its waiting loop
* early. */
waitForMediaTask.cancel(true);
}
});
waitForMediaTask.execute();
}
private void onReadyFailure(Exception exception, final String content, final String visibility,
final boolean sensitive) {
doErrorDialog(R.string.error_media_upload_sending, R.string.action_retry,
new View.OnClickListener() {
@Override
public void onClick(View v) {
readyStatus(content, visibility, sensitive);
}
});
}
@Override
@ -439,16 +458,26 @@ public class ComposeActivity extends AppCompatActivity {
cancelReadyingMedia(item);
}
private void removeAllMediaFromQueue() {
for (Iterator<QueuedMedia> it = mediaQueued.iterator(); it.hasNext();) {
QueuedMedia item = it.next();
it.remove();
removeMediaFromQueue(item);
}
}
private void downsizeMedia(final QueuedMedia item) {
item.setReadyStage(QueuedMedia.ReadyStage.DOWNSIZING);
InputStream stream;
InputStream stream = null;
try {
stream = getContentResolver().openInputStream(item.getUri());
} catch (FileNotFoundException e) {
IOUtils.closeQuietly(stream);
onMediaDownsizeFailure(item);
return;
}
Bitmap bitmap = BitmapFactory.decodeStream(stream);
IOUtils.closeQuietly(stream);
new DownsizeImageTask(STATUS_MEDIA_SIZE_LIMIT, new DownsizeImageTask.Listener() {
@Override
public void onSuccess(List<byte[]> contentList) {
@ -538,13 +567,15 @@ public class ComposeActivity extends AppCompatActivity {
public DataItem getData() {
byte[] content = item.getContent();
if (content == null) {
InputStream stream;
InputStream stream = null;
try {
stream = getContentResolver().openInputStream(item.getUri());
} catch (FileNotFoundException e) {
IOUtils.closeQuietly(stream);
return null;
}
content = inputStreamGetBytes(stream);
IOUtils.closeQuietly(stream);
if (content == null) {
return null;
}
@ -607,10 +638,11 @@ public class ComposeActivity extends AppCompatActivity {
break;
}
case "image": {
InputStream stream;
InputStream stream = null;
try {
stream = contentResolver.openInputStream(uri);
} catch (FileNotFoundException e) {
IOUtils.closeQuietly(stream);
displayTransientError(R.string.error_media_upload_opening);
return;
}
@ -618,7 +650,9 @@ public class ComposeActivity extends AppCompatActivity {
Bitmap bitmap = ThumbnailUtils.extractThumbnail(source, 96, 96);
source.recycle();
try {
stream.close();
if (stream != null) {
stream.close();
}
} catch (IOException e) {
bitmap.recycle();
displayTransientError(R.string.error_media_upload_opening);

View File

@ -0,0 +1,5 @@
package com.keylesspalace.tusky;
public interface FooterActionListener {
void onLoadMore();
}

View File

@ -0,0 +1,18 @@
package com.keylesspalace.tusky;
import android.support.annotation.Nullable;
import java.io.IOException;
import java.io.InputStream;
public class IOUtils {
public static void closeQuietly(@Nullable InputStream stream) {
try {
if (stream != null) {
stream.close();
}
} catch (IOException e) {
// intentionally unhandled
}
}
}

View File

@ -9,8 +9,11 @@ import android.text.style.ImageSpan;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.ProgressBar;
import android.widget.TextView;
import com.android.volley.toolbox.ImageLoader;
@ -21,71 +24,101 @@ import java.util.Date;
import java.util.List;
public class TimelineAdapter extends RecyclerView.Adapter {
private List<Status> statuses = new ArrayList<>();
private static final int VIEW_TYPE_STATUS = 0;
private static final int VIEW_TYPE_FOOTER = 1;
StatusActionListener listener;
private List<Status> statuses;
private StatusActionListener statusListener;
private FooterActionListener footerListener;
public TimelineAdapter(StatusActionListener listener) {
public TimelineAdapter(StatusActionListener statusListener,
FooterActionListener footerListener) {
super();
this.listener = listener;
statuses = new ArrayList<>();
this.statusListener = statusListener;
this.footerListener = footerListener;
}
@Override
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup viewGroup, int viewType) {
View v = LayoutInflater.from(viewGroup.getContext())
.inflate(R.layout.item_status, viewGroup, false);
return new ViewHolder(v);
switch (viewType) {
default:
case VIEW_TYPE_STATUS: {
View view = LayoutInflater.from(viewGroup.getContext())
.inflate(R.layout.item_status, viewGroup, false);
return new StatusViewHolder(view);
}
case VIEW_TYPE_FOOTER: {
View view = LayoutInflater.from(viewGroup.getContext())
.inflate(R.layout.item_footer, viewGroup, false);
return new FooterViewHolder(view);
}
}
}
@Override
public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) {
ViewHolder holder = (ViewHolder) viewHolder;
Status status = statuses.get(position);
holder.setDisplayName(status.getDisplayName());
holder.setUsername(status.getUsername());
holder.setCreatedAt(status.getCreatedAt());
holder.setContent(status.getContent());
holder.setAvatar(status.getAvatar());
holder.setContent(status.getContent());
holder.setReblogged(status.getReblogged());
holder.setFavourited(status.getFavourited());
String rebloggedByUsername = status.getRebloggedByUsername();
if (rebloggedByUsername == null) {
holder.hideRebloggedByUsername();
if (position < statuses.size()) {
StatusViewHolder holder = (StatusViewHolder) viewHolder;
Status status = statuses.get(position);
holder.setDisplayName(status.getDisplayName());
holder.setUsername(status.getUsername());
holder.setCreatedAt(status.getCreatedAt());
holder.setContent(status.getContent());
holder.setAvatar(status.getAvatar());
holder.setContent(status.getContent());
holder.setReblogged(status.getReblogged());
holder.setFavourited(status.getFavourited());
String rebloggedByUsername = status.getRebloggedByUsername();
if (rebloggedByUsername == null) {
holder.hideRebloggedByUsername();
} else {
holder.setRebloggedByUsername(rebloggedByUsername);
}
Status.MediaAttachment[] attachments = status.getAttachments();
boolean sensitive = status.getSensitive();
holder.setMediaPreviews(attachments, sensitive, statusListener);
/* A status without attachments is sometimes still marked sensitive, so it's necessary
* to check both whether there are any attachments and if it's marked sensitive. */
if (!sensitive || attachments.length == 0) {
holder.hideSensitiveMediaWarning();
}
holder.setupButtons(statusListener, position);
if (status.getVisibility() == Status.Visibility.PRIVATE) {
holder.disableReblogging();
}
} else {
holder.setRebloggedByUsername(rebloggedByUsername);
}
Status.MediaAttachment[] attachments = status.getAttachments();
boolean sensitive = status.getSensitive();
holder.setMediaPreviews(attachments, sensitive, listener);
/* A status without attachments is sometimes still marked sensitive, so it's necessary
* to check both whether there are any attachments and if it's marked sensitive. */
if (!sensitive || attachments.length == 0) {
holder.hideSensitiveMediaWarning();
}
holder.setupButtons(listener, position);
if (status.getVisibility() == Status.Visibility.PRIVATE) {
holder.disableReblogging();
FooterViewHolder holder = (FooterViewHolder) viewHolder;
holder.setupButton(footerListener);
}
}
@Override
public int getItemCount() {
return statuses.size();
return statuses.size() + 1;
}
public int update(List<Status> new_statuses) {
@Override
public int getItemViewType(int position) {
if (position == statuses.size()) {
return VIEW_TYPE_FOOTER;
} else {
return VIEW_TYPE_STATUS;
}
}
public int update(List<Status> newStatuses) {
int scrollToPosition;
if (statuses == null || statuses.isEmpty()) {
statuses = new_statuses;
statuses = newStatuses;
scrollToPosition = 0;
} else {
int index = new_statuses.indexOf(statuses.get(0));
int index = newStatuses.indexOf(statuses.get(0));
if (index == -1) {
statuses.addAll(0, new_statuses);
statuses.addAll(0, newStatuses);
scrollToPosition = 0;
} else {
statuses.addAll(0, new_statuses.subList(0, index));
statuses.addAll(0, newStatuses.subList(0, index));
scrollToPosition = index;
}
}
@ -93,10 +126,10 @@ public class TimelineAdapter extends RecyclerView.Adapter {
return scrollToPosition;
}
public void addItems(List<Status> new_statuses) {
public void addItems(List<Status> newStatuses) {
int end = statuses.size();
statuses.addAll(new_statuses);
notifyItemRangeInserted(end, new_statuses.size());
statuses.addAll(newStatuses);
notifyItemRangeInserted(end, newStatuses.size());
}
public void removeItem(int position) {
@ -104,11 +137,14 @@ public class TimelineAdapter extends RecyclerView.Adapter {
notifyItemRemoved(position);
}
public Status getItem(int position) {
return statuses.get(position);
public @Nullable Status getItem(int position) {
if (position >= 0 && position < statuses.size()) {
return statuses.get(position);
}
return null;
}
public static class ViewHolder extends RecyclerView.ViewHolder {
public static class StatusViewHolder extends RecyclerView.ViewHolder {
private TextView displayName;
private TextView username;
private TextView sinceCreated;
@ -128,7 +164,7 @@ public class TimelineAdapter extends RecyclerView.Adapter {
private NetworkImageView mediaPreview3;
private View sensitiveMediaWarning;
public ViewHolder(View itemView) {
public StatusViewHolder(View itemView) {
super(itemView);
displayName = (TextView) itemView.findViewById(R.id.status_display_name);
username = (TextView) itemView.findViewById(R.id.status_username);
@ -331,4 +367,37 @@ public class TimelineAdapter extends RecyclerView.Adapter {
});
}
}
public static class FooterViewHolder extends RecyclerView.ViewHolder {
private LinearLayout retryBar;
private Button retry;
private ProgressBar progressBar;
public FooterViewHolder(View itemView) {
super(itemView);
retryBar = (LinearLayout) itemView.findViewById(R.id.footer_retry_bar);
retry = (Button) itemView.findViewById(R.id.footer_retry_button);
progressBar = (ProgressBar) itemView.findViewById(R.id.footer_progress_bar);
progressBar.setIndeterminate(true);
}
public void setupButton(final FooterActionListener listener) {
retry.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
listener.onLoadMore();
}
});
}
public void showRetry(boolean show) {
if (!show) {
retryBar.setVisibility(View.GONE);
progressBar.setVisibility(View.VISIBLE);
} else {
retryBar.setVisibility(View.VISIBLE);
progressBar.setVisibility(View.GONE);
}
}
}
}

View File

@ -6,6 +6,7 @@ import android.content.SharedPreferences;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.support.annotation.Nullable;
import android.support.design.widget.TabLayout;
import android.support.v4.app.Fragment;
import android.support.v4.app.FragmentManager;
import android.support.v4.content.ContextCompat;
@ -18,7 +19,6 @@ import android.view.LayoutInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Toast;
import com.android.volley.AuthFailureError;
import com.android.volley.Request;
@ -36,7 +36,7 @@ import java.util.List;
import java.util.Map;
public class TimelineFragment extends Fragment implements
SwipeRefreshLayout.OnRefreshListener, StatusActionListener {
SwipeRefreshLayout.OnRefreshListener, StatusActionListener, FooterActionListener {
public enum Kind {
HOME,
@ -48,8 +48,12 @@ public class TimelineFragment extends Fragment implements
private String accessToken = null;
private String userAccountId = null;
private SwipeRefreshLayout swipeRefreshLayout;
private RecyclerView recyclerView;
private TimelineAdapter adapter;
private Kind kind;
private LinearLayoutManager layoutManager;
private EndlessOnScrollListener scrollListener;
private TabLayout.OnTabSelectedListener onTabSelectedListener;
public static TimelineFragment newInstance(Kind kind) {
TimelineFragment fragment = new TimelineFragment();
@ -79,33 +83,65 @@ public class TimelineFragment extends Fragment implements
swipeRefreshLayout = (SwipeRefreshLayout) rootView.findViewById(R.id.swipe_refresh_layout);
swipeRefreshLayout.setOnRefreshListener(this);
// Setup the RecyclerView.
RecyclerView recyclerView = (RecyclerView) rootView.findViewById(R.id.recycler_view);
recyclerView = (RecyclerView) rootView.findViewById(R.id.recycler_view);
recyclerView.setHasFixedSize(true);
LinearLayoutManager layoutManager = new LinearLayoutManager(context);
layoutManager = new LinearLayoutManager(context);
recyclerView.setLayoutManager(layoutManager);
DividerItemDecoration divider = new DividerItemDecoration(
context, layoutManager.getOrientation());
Drawable drawable = ContextCompat.getDrawable(context, R.drawable.status_divider);
divider.setDrawable(drawable);
recyclerView.addItemDecoration(divider);
EndlessOnScrollListener scrollListener = new EndlessOnScrollListener(layoutManager) {
scrollListener = new EndlessOnScrollListener(layoutManager) {
@Override
public void onLoadMore(int page, int totalItemsCount, RecyclerView view) {
TimelineAdapter adapter = (TimelineAdapter) view.getAdapter();
String fromId = adapter.getItem(adapter.getItemCount() - 1).getId();
sendFetchTimelineRequest(fromId);
Status status = adapter.getItem(adapter.getItemCount() - 2);
if (status != null) {
sendFetchTimelineRequest(status.getId());
} else {
sendFetchTimelineRequest();
}
}
};
recyclerView.addOnScrollListener(scrollListener);
adapter = new TimelineAdapter(this);
adapter = new TimelineAdapter(this, this);
recyclerView.setAdapter(adapter);
TabLayout layout = (TabLayout) getActivity().findViewById(R.id.tab_layout);
onTabSelectedListener = new TabLayout.OnTabSelectedListener() {
@Override
public void onTabSelected(TabLayout.Tab tab) {}
@Override
public void onTabUnselected(TabLayout.Tab tab) {}
@Override
public void onTabReselected(TabLayout.Tab tab) {
jumpToTop();
}
};
layout.addOnTabSelectedListener(onTabSelectedListener);
sendUserInfoRequest();
sendFetchTimelineRequest();
return rootView;
}
@Override
public void onDestroyView() {
TabLayout tabLayout = (TabLayout) getActivity().findViewById(R.id.tab_layout);
tabLayout.removeOnTabSelectedListener(onTabSelectedListener);
super.onDestroyView();
}
private void jumpToTop() {
layoutManager.scrollToPositionWithOffset(0, 0);
scrollListener.reset();
}
private void sendUserInfoRequest() {
sendRequest(Request.Method.GET, getString(R.string.endpoint_verify_credentials), null,
new Response.Listener<JSONObject>() {
@ -182,15 +218,24 @@ public class TimelineFragment extends Fragment implements
} else {
adapter.update(statuses);
}
showFetchTimelineRetry(false);
swipeRefreshLayout.setRefreshing(false);
}
public void onFetchTimelineFailure(Exception exception) {
Toast.makeText(getContext(), R.string.error_fetching_timeline, Toast.LENGTH_SHORT)
.show();
showFetchTimelineRetry(true);
swipeRefreshLayout.setRefreshing(false);
}
private void showFetchTimelineRetry(boolean show) {
RecyclerView.ViewHolder viewHolder =
recyclerView.findViewHolderForAdapterPosition(adapter.getItemCount() - 1);
if (viewHolder != null) {
TimelineAdapter.FooterViewHolder holder = (TimelineAdapter.FooterViewHolder) viewHolder;
holder.showRetry(show);
}
}
public void onRefresh() {
sendFetchTimelineRequest();
}
@ -335,4 +380,13 @@ public class TimelineFragment extends Fragment implements
}
}
}
public void onLoadMore() {
Status status = adapter.getItem(adapter.getItemCount() - 2);
if (status != null) {
sendFetchTimelineRequest(status.getId());
} else {
sendFetchTimelineRequest();
}
}
}

View File

@ -49,6 +49,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/title_public" />
</android.support.design.widget.TabLayout>
</android.support.v4.view.ViewPager>

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_gravity="center">
<LinearLayout
android:id="@+id/footer_retry_bar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/footer_text"
android:padding="@dimen/footer_text_padding"/>
<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/footer_retry_button"
android:text="@string/action_retry" />
</LinearLayout>
<ProgressBar
android:id="@+id/footer_progress_bar"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
</LinearLayout>

View File

@ -7,6 +7,7 @@
<dimen name="status_boost_icon_vertical_padding">5dp</dimen>
<dimen name="status_media_preview_top_margin">4dp</dimen>
<dimen name="status_media_preview_height">96dp</dimen>
<dimen name="footer_text_padding">8dp</dimen>
<dimen name="compose_media_preview_margin">8dp</dimen>
<dimen name="compose_media_preview_margin_bottom">16dp</dimen>
<dimen name="compose_media_preview_side">48dp</dimen>

View File

@ -58,6 +58,8 @@
<string name="status_sensitive_media_title">Sensitive Media</string>
<string name="status_sensitive_media_directions">Click to view.</string>
<string name="footer_text">Could not load the rest of the toots.</string>
<string name="notification_reblog_format">%s boosted your status</string>
<string name="notification_favourite_format">%s favourited your status</string>
<string name="notification_follow_format">%s followed you</string>
@ -71,6 +73,7 @@
<string name="action_send">TOOT</string>
<string name="action_retry">Retry</string>
<string name="action_mark_sensitive">Mark Sensitive</string>
<string name="action_cancel">Cancel</string>
<string name="description_domain">Domain</string>
<string name="description_compose">What\'s Happening?</string>