package com.appboy.ui;
import android.annotation.TargetApi;
import android.app.Activity;
import android.app.ListFragment;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.support.v4.view.GestureDetectorCompat;
import android.support.v4.widget.SwipeRefreshLayout;
import android.view.GestureDetector;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AbsListView;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.ProgressBar;
import android.widget.RelativeLayout;
import com.appboy.Appboy;
import com.appboy.Constants;
import com.appboy.enums.CardCategory;
import com.appboy.events.FeedUpdatedEvent;
import com.appboy.events.IEventSubscriber;
import com.appboy.models.cards.Card;
import com.appboy.support.AppboyLogger;
import com.appboy.ui.adapters.AppboyListAdapter;
import java.util.ArrayList;
import java.util.EnumSet;
@TargetApi(11)
public class AppboyXamarinFormsFeedFragment extends ListFragment implements SwipeRefreshLayout.OnRefreshListener {
private static final String TAG = String.format("%s.%s", Constants.APPBOY_LOG_TAG_PREFIX, AppboyXamarinFormsFeedFragment.class.getName());
private static final int NETWORK_PROBLEM_WARNING_MS = 5000;
private static final int MAX_FEED_TTL_SECONDS = 60;
private static final long AUTO_HIDE_REFRESH_INDICATOR_DELAY_MS = 2500L;
private final Handler mMainThreadLooper = new Handler(Looper.getMainLooper());
// Shows the network error message. This should only be executed on the Main/UI thread.
private final Runnable mShowNetworkError = new Runnable() {
@Override
public void run() {
// null checks make sure that this only executes when the constituent views are valid references.
if (mLoadingSpinner != null) {
mLoadingSpinner.setVisibility(View.GONE);
}
if (mNetworkErrorLayout != null) {
mNetworkErrorLayout.setVisibility(View.VISIBLE);
}
}
};
private Appboy mAppboy;
private IEventSubscriber<FeedUpdatedEvent> mFeedUpdatedSubscriber;
private AppboyListAdapter mAdapter;
private LinearLayout mNetworkErrorLayout;
private LinearLayout mEmptyFeedLayout;
private ProgressBar mLoadingSpinner;
private RelativeLayout mFeedRootLayout;
private boolean mSkipCardImpressionsReset;
private EnumSet<CardCategory> mCategories;
private SwipeRefreshLayout mFeedSwipeLayout;
private int previousVisibleHeadCardIndex;
private int currentCardIndexAtBottomOfScreen;
private GestureDetectorCompat mGestureDetector;
// This view should only be in the View.VISIBLE state when the listview is not visible. This view's
// purpose is to let the "network error" and "no card" states to have the swipe-to-refresh functionality
// when their respective views are visible.
private View mTransparentFullBoundsContainerView;
public AppboyXamarinFormsFeedFragment() {
}
@Override
public void onAttach(final Activity activity) {
super.onAttach(activity);
mAppboy = Appboy.getInstance(activity);
if (mAdapter == null) {
mAdapter = new AppboyListAdapter(activity, R.id.tag, new ArrayList<Card>());
mCategories = CardCategory.getAllCategories();
}
setRetainInstance(true);
mGestureDetector = new GestureDetectorCompat(activity, new FeedGestureListener());
}
@Override
public View onCreateView(LayoutInflater layoutInflater, ViewGroup container, Bundle savedInstanceState) {
View view = layoutInflater.inflate(R.layout.com_appboy_feed, container, false);
mNetworkErrorLayout = (LinearLayout) view.findViewById(R.id.com_appboy_feed_network_error);
mLoadingSpinner = (ProgressBar) view.findViewById(R.id.com_appboy_feed_loading_spinner);
mEmptyFeedLayout = (LinearLayout) view.findViewById(R.id.com_appboy_feed_empty_feed);
mFeedRootLayout = (RelativeLayout) view.findViewById(R.id.com_appboy_feed_root);
mFeedSwipeLayout = (SwipeRefreshLayout) view.findViewById(R.id.appboy_feed_swipe_container);
mFeedSwipeLayout.setOnRefreshListener(this);
mFeedSwipeLayout.setEnabled(false);
mFeedSwipeLayout.setColorSchemeResources(R.color.com_appboy_newsfeed_swipe_refresh_color_1,
R.color.com_appboy_newsfeed_swipe_refresh_color_2,
R.color.com_appboy_newsfeed_swipe_refresh_color_3,
R.color.com_appboy_newsfeed_swipe_refresh_color_4);
mTransparentFullBoundsContainerView = view.findViewById(R.id.com_appboy_feed_transparent_full_bounds_container_view);
return view;
}
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
if (mSkipCardImpressionsReset) {
mSkipCardImpressionsReset = false;
} else {
mAdapter.resetCardImpressionTracker();
AppboyLogger.d(TAG, "Resetting card impressions.");
}
// Applying top and bottom padding as header and footer views allows for the top and bottom padding to be scrolled
// away, as opposed to being a permanent frame around the feed.
LayoutInflater inflater = LayoutInflater.from(getActivity());
final ListView listView = getListView();
listView.addHeaderView(inflater.inflate(R.layout.com_appboy_feed_header, null));
listView.addFooterView(inflater.inflate(R.layout.com_appboy_feed_footer, null));
mFeedRootLayout.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View view, MotionEvent motionEvent) {
// Send touch events from the background view to the gesture detector to enable margin listview scrolling
return mGestureDetector.onTouchEvent(motionEvent);
}
});
// Enable the swipe-to-refresh view only when the user is at the head of the listview.
listView.setOnScrollListener(new AbsListView.OnScrollListener() {
@Override
public void onScrollStateChanged(AbsListView absListView, int scrollState) {
}
@Override
public void onScroll(AbsListView absListView, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
mFeedSwipeLayout.setEnabled(firstVisibleItem == 0);
// Handle read/unread cards functionality below
if (visibleItemCount == 0) {
// No cards/views have been loaded, do nothing
return;
}
int currentVisibleHeadCardIndex = firstVisibleItem - 1;
// Head index increased (scroll down)
if (currentVisibleHeadCardIndex > previousVisibleHeadCardIndex) {
// Mark all cards in the gap as read
mAdapter.batchSetCardsToRead(previousVisibleHeadCardIndex, currentVisibleHeadCardIndex);
}
previousVisibleHeadCardIndex = currentVisibleHeadCardIndex;
// We take note of what card is at the bottom of the feed so that when this fragment is destroyed,
// all on-screen cards have updated read indicators.
currentCardIndexAtBottomOfScreen = firstVisibleItem + visibleItemCount;
}
});
// We need the transparent view to pass it's touch events to the swipe-to-refresh view. We
// do this by consuming touch events in the transparent view.
mTransparentFullBoundsContainerView.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View view, MotionEvent motionEvent) {
// Only consume events if the view is visible
return view.getVisibility() == View.VISIBLE;
}
});
// Remove the previous subscriber before rebuilding a new one with our new activity.
mAppboy.removeSingleSubscription(mFeedUpdatedSubscriber, FeedUpdatedEvent.class);
mFeedUpdatedSubscriber = new IEventSubscriber<FeedUpdatedEvent>() {
@Override
public void trigger(final FeedUpdatedEvent event) {
Activity activity = getActivity();
// Not strictly necessary, but being defensive in the face of a lot of inconsistent behavior with
// fragment/activity lifecycles.
if (activity == null) {
return;
}
activity.runOnUiThread(new Runnable() {
@Override
public void run() {
AppboyLogger.d(TAG, "Updating feed views in response to FeedUpdatedEvent: " + event);
// If a FeedUpdatedEvent comes in, we make sure that the network error isn't visible. It could become
// visible again later if we need to request a new feed and it doesn't return in time, but we display a
// network spinner while we wait, instead of keeping the network error up.
mMainThreadLooper.removeCallbacks(mShowNetworkError);
mNetworkErrorLayout.setVisibility(View.GONE);
// If there are no cards, regardless of what happens further down, we're not going to show the list view, so
// clear the list view and change relevant visibility now.
if (event.getCardCount(mCategories) == 0) {
listView.setVisibility(View.GONE);
mAdapter.clear();
} else {
mEmptyFeedLayout.setVisibility(View.GONE);
mLoadingSpinner.setVisibility(View.GONE);
mTransparentFullBoundsContainerView.setVisibility(View.GONE);
}
// If we got our feed from offline storage, and it was old, we asynchronously request a new one from the server,
// putting up a spinner if the old feed was empty.
if (event.isFromOfflineStorage() && (event.lastUpdatedInSecondsFromEpoch() + MAX_FEED_TTL_SECONDS) * 1000 < System.currentTimeMillis()) {
AppboyLogger.i(TAG, String.format("Feed received was older than the max time to live of %d seconds, displaying it "
+ "for now, but requesting an updated view from the server.", MAX_FEED_TTL_SECONDS));
mAppboy.requestFeedRefresh();
// If we don't have any cards to display, we put up the spinner while we wait for the network to return.
// Eventually displaying an error message if it doesn't.
if (event.getCardCount(mCategories) == 0) {
AppboyLogger.d(TAG, String.format("Old feed was empty, putting up a network spinner and registering the network error message on a delay of %dms.",
NETWORK_PROBLEM_WARNING_MS));
mEmptyFeedLayout.setVisibility(View.GONE);
mLoadingSpinner.setVisibility(View.VISIBLE);
mTransparentFullBoundsContainerView.setVisibility(View.VISIBLE);
mMainThreadLooper.postDelayed(mShowNetworkError, NETWORK_PROBLEM_WARNING_MS);
return;
}
}
// If we get here, we know that our feed is either fresh from the cache, or came down directly from a
// network request. Thus, an empty feed shouldn't have a network error, or a spinner, we should just
// tell the user that the feed is empty.
if (event.getCardCount(mCategories) == 0) {
mLoadingSpinner.setVisibility(View.GONE);
mEmptyFeedLayout.setVisibility(View.VISIBLE);
mTransparentFullBoundsContainerView.setVisibility(View.VISIBLE);
} else {
mAdapter.replaceFeed(event.getFeedCards(mCategories));
listView.setVisibility(View.VISIBLE);
}
mFeedSwipeLayout.setRefreshing(false);
}
});
}
};
mAppboy.subscribeToFeedUpdates(mFeedUpdatedSubscriber);
// Once the header and footer views are set and our event handlers are ready to go, we set the adapter and hit the
// cache for an initial feed load.
listView.setAdapter(mAdapter);
mAppboy.requestFeedRefreshFromCache();
}
@Override
public void onResume() {
super.onResume();
Appboy.getInstance(getActivity()).logFeedDisplayed();
}
@Override
public void onDestroyView() {
super.onDestroyView();
// If the view is destroyed, we don't care about updating it anymore. Remove the subscription immediately.
mAppboy.removeSingleSubscription(mFeedUpdatedSubscriber, FeedUpdatedEvent.class);
setOnScreenCardsToRead();
}
@Override
public void onPause() {
super.onPause();
setOnScreenCardsToRead();
}
/**
* This should be called whenever the feed goes off the user's screen.
*/
private void setOnScreenCardsToRead() {
// Set whatever cards are on screen to read since the view is being destroyed.
mAdapter.batchSetCardsToRead(previousVisibleHeadCardIndex, currentCardIndexAtBottomOfScreen);
}
@Override
public void onDetach() {
super.onDetach();
setListAdapter(null);
}
// The onSaveInstanceState method gets called before an orientation change when either the fragment is
// the current fragment or exists in the fragment manager backstack.
@Override
public void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
// We set mSkipCardImpressionsReset to true only when onSaveInstanceState is called while the fragment
// is visible on the screen. That happens when the fragment is being managed by the fragment manager and
// it is not in the backstack. We do this to avoid setting the mSkipCardImpressionsReset flag when the
// device undergoes an orientation change while the fragment is in the backstack.
if (isVisible()) {
mSkipCardImpressionsReset = true;
}
}
public EnumSet<CardCategory> getCategories() {
return mCategories;
}
public void setCategory(CardCategory category) {
setCategories(EnumSet.of(category));
}
/**
* Calling this method will make AppboyFeedFragment display a list of cards where each card belongs
* to at least one of the given categories.
* When there are no cards in those categories, this method returns an empty list.
* When the passed in categories are null, all cards will be returned.
* When the passed in categories are empty EnumSet, an empty list will be returned.
*
* @param categories an EnumSet of CardCategory. Please pass in a non-empty EnumSet of CardCategory,
* or a null. An empty EnumSet is considered invalid.
*/
public void setCategories(EnumSet<CardCategory> categories) {
if (categories == null) {
AppboyLogger.i(TAG, "The categories passed into setCategories are null, AppboyFeedFragment is going to display all the cards in cache.");
mCategories = CardCategory.getAllCategories();
} else if (categories.isEmpty()) {
AppboyLogger.w(TAG, "The categories set had no elements and have been ignored. Please pass a valid EnumSet of CardCategory.");
return;
} else if (categories.equals(mCategories)) {
return;
} else {
mCategories = categories;
}
if (mAppboy != null) {
mAppboy.requestFeedRefreshFromCache();
}
}
// Called when the user swipes down and requests a feed refresh.
@Override
public void onRefresh() {
mAppboy.requestFeedRefresh();
mMainThreadLooper.postDelayed(new Runnable() {
@Override
public void run() {
mFeedSwipeLayout.setRefreshing(false);
}
}, AUTO_HIDE_REFRESH_INDICATOR_DELAY_MS);
}
// This class is a custom listener to catch gestures happening outside the bounds of the listview that
// should be fed into it.
public class FeedGestureListener extends GestureDetector.SimpleOnGestureListener {
@Override
public boolean onDown(MotionEvent motionEvent) {
return true;
}
@Override
public boolean onScroll(MotionEvent motionEvent, MotionEvent motionEvent2, float dx, float dy) {
getListView().smoothScrollBy((int) dy, 0);
return true;
}
@Override
public boolean onFling(MotionEvent motionEvent, MotionEvent motionEvent2, float velocityX, float velocityY) {
// We need to find the pixel distance of the scroll from the velocity with units (px / sec)
// So d (px) = v (px / sec) * 1 (sec) / 1000 (ms) * deltaTimeMillis (ms)
long deltaTimeMillis = (motionEvent2.getEventTime() - motionEvent.getEventTime()) * 2;
int scrollDistance = (int) (velocityY * deltaTimeMillis / 1000);
// Multiplied by 2 to get a smoother scroll effect during a fling
getListView().smoothScrollBy(-scrollDistance, (int) (deltaTimeMillis * 2));
return true;
}
}
}