From 2106d7a53cb1667cf54a0a9dcd505c2870a68cde Mon Sep 17 00:00:00 2001 From: Vavassor Date: Wed, 18 Jan 2017 13:35:07 -0500 Subject: [PATCH] Jumping to top capability and a progress/retry footer added to timelines. --- .../keylesspalace/tusky/ComposeActivity.java | 70 ++++++-- .../tusky/FooterActionListener.java | 5 + .../java/com/keylesspalace/tusky/IOUtils.java | 18 ++ .../keylesspalace/tusky/TimelineAdapter.java | 161 +++++++++++++----- .../keylesspalace/tusky/TimelineFragment.java | 74 ++++++-- app/src/main/res/layout/activity_main.xml | 1 + app/src/main/res/layout/item_footer.xml | 41 +++++ app/src/main/res/values/dimens.xml | 1 + app/src/main/res/values/strings.xml | 3 + 9 files changed, 300 insertions(+), 74 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/FooterActionListener.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/IOUtils.java create mode 100644 app/src/main/res/layout/item_footer.xml diff --git a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java b/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java index 885885faf..fca70e577 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java @@ -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() { + "Uploading...", true, true); + final AsyncTask waitForMediaTask = + new AsyncTask() { 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 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 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); diff --git a/app/src/main/java/com/keylesspalace/tusky/FooterActionListener.java b/app/src/main/java/com/keylesspalace/tusky/FooterActionListener.java new file mode 100644 index 000000000..63e79b8de --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/FooterActionListener.java @@ -0,0 +1,5 @@ +package com.keylesspalace.tusky; + +public interface FooterActionListener { + void onLoadMore(); +} diff --git a/app/src/main/java/com/keylesspalace/tusky/IOUtils.java b/app/src/main/java/com/keylesspalace/tusky/IOUtils.java new file mode 100644 index 000000000..2987c3152 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/IOUtils.java @@ -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 + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/TimelineAdapter.java b/app/src/main/java/com/keylesspalace/tusky/TimelineAdapter.java index 7b78ad8a4..45ef42980 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TimelineAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/TimelineAdapter.java @@ -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 statuses = new ArrayList<>(); + private static final int VIEW_TYPE_STATUS = 0; + private static final int VIEW_TYPE_FOOTER = 1; - StatusActionListener listener; + private List 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 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 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 new_statuses) { + public void addItems(List 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); + } + } + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/TimelineFragment.java b/app/src/main/java/com/keylesspalace/tusky/TimelineFragment.java index 609531518..4d161a814 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TimelineFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/TimelineFragment.java @@ -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() { @@ -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(); + } + } } diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index a65e5f3b1..e75083235 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -49,6 +49,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/title_public" /> + diff --git a/app/src/main/res/layout/item_footer.xml b/app/src/main/res/layout/item_footer.xml new file mode 100644 index 000000000..5c517303a --- /dev/null +++ b/app/src/main/res/layout/item_footer.xml @@ -0,0 +1,41 @@ + + + + + + + + + +