1package com.android.mail.ui;
2
3import android.animation.Animator;
4import android.animation.AnimatorListenerAdapter;
5import android.app.Activity;
6import android.content.Context;
7import android.content.res.TypedArray;
8import android.graphics.PixelFormat;
9import android.graphics.Rect;
10import android.util.AttributeSet;
11import android.util.DisplayMetrics;
12import android.view.Gravity;
13import android.view.LayoutInflater;
14import android.view.MotionEvent;
15import android.view.View;
16import android.view.Window;
17import android.view.WindowManager;
18import android.view.animation.AccelerateInterpolator;
19import android.view.animation.DecelerateInterpolator;
20import android.view.animation.Interpolator;
21import android.widget.FrameLayout;
22import android.widget.TextView;
23import android.widget.Toast;
24
25import com.android.mail.ConversationListContext;
26import com.android.mail.R;
27import com.android.mail.analytics.Analytics;
28import com.android.mail.preferences.AccountPreferences;
29import com.android.mail.preferences.MailPrefs;
30import com.android.mail.providers.UIProvider.FolderCapabilities;
31import com.android.mail.providers.UIProvider.FolderType;
32import com.android.mail.ui.ConversationSyncDisabledTipView.ReasonSyncOff;
33import com.android.mail.utils.LogTag;
34import com.android.mail.utils.LogUtils;
35import com.android.mail.utils.Utils;
36
37/**
38 * Conversation list view contains a {@link SwipeableListView} and a sync status bar above it.
39 */
40public class ConversationListView extends FrameLayout implements SwipeableListView.SwipeListener {
41
42    private static final int MIN_DISTANCE_TO_TRIGGER_SYNC = 150; // dp
43    private static final int MAX_DISTANCE_TO_TRIGGER_SYNC = 300; // dp
44
45    private static final int DISTANCE_TO_IGNORE = 15; // dp
46    private static final int DISTANCE_TO_TRIGGER_CANCEL = 10; // dp
47    private static final int SHOW_CHECKING_FOR_MAIL_DURATION_IN_MILLIS = 1 * 1000; // 1 seconds
48
49    private static final int SWIPE_TEXT_APPEAR_DURATION_IN_MILLIS = 200;
50    private static final int SYNC_STATUS_BAR_FADE_DURATION_IN_MILLIS = 150;
51    private static final int SYNC_TRIGGER_SHRINK_DURATION_IN_MILLIS = 250;
52
53    // Max number of times we display the same sync turned off warning message in a toast.
54    // After we reach this max, and device/account still has sync off, we assume user has
55    // intentionally disabled sync and no longer warn.
56    private static final int MAX_NUM_OF_SYNC_TOASTS = 5;
57
58    private static final String LOG_TAG = LogTag.getLogTag();
59
60    private View mSyncTriggerBar;
61    private View mSyncProgressBar;
62    private final AnimatorListenerAdapter mSyncDismissListener;
63    private SwipeableListView mListView;
64
65    // Whether to ignore events in {#dispatchTouchEvent}.
66    private boolean mIgnoreTouchEvents = false;
67
68    private boolean mTrackingScrollMovement = false;
69    // Y coordinate of where scroll started
70    private float mTrackingScrollStartY;
71    // Max Y coordinate reached since starting scroll, this is used to know whether
72    // user moved back up which should cancel the current tracking state and hide the
73    // sync trigger bar.
74    private float mTrackingScrollMaxY;
75    private boolean mIsSyncing = false;
76
77    private final Interpolator mAccelerateInterpolator = new AccelerateInterpolator(1.5f);
78    private final Interpolator mDecelerateInterpolator = new DecelerateInterpolator(1.5f);
79
80    private float mDensity;
81
82    private ControllableActivity mActivity;
83    private final WindowManager mWindowManager;
84    private final HintText mHintText;
85    private boolean mHasHintTextViewBeenAdded = false;
86
87    // Minimum vertical distance (in dips) of swipe to trigger a sync.
88    // This value can be different based on the device.
89    private float mDistanceToTriggerSyncDp = MIN_DISTANCE_TO_TRIGGER_SYNC;
90
91    private ConversationListContext mConvListContext;
92
93    private final MailPrefs mMailPrefs;
94    private AccountPreferences mAccountPreferences;
95
96    // Instantiated through view inflation
97    @SuppressWarnings("unused")
98    public ConversationListView(Context context) {
99        this(context, null);
100    }
101
102    public ConversationListView(Context context, AttributeSet attrs) {
103        this(context, attrs, -1);
104    }
105
106    public ConversationListView(Context context, AttributeSet attrs, int defStyle) {
107        super(context, attrs, defStyle);
108
109        mWindowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
110        mHintText = new ConversationListView.HintText(context);
111
112        mSyncDismissListener = new AnimatorListenerAdapter() {
113            @Override
114            public void onAnimationEnd(Animator arg0) {
115                mSyncProgressBar.setVisibility(GONE);
116                mSyncTriggerBar.setVisibility(GONE);
117            }
118        };
119
120        mMailPrefs = MailPrefs.get(context);
121    }
122
123    @Override
124    protected void onFinishInflate() {
125        super.onFinishInflate();
126        mListView = (SwipeableListView) findViewById(android.R.id.list);
127        mListView.setSwipeListener(this);
128
129        DisplayMetrics displayMetrics = getResources().getDisplayMetrics();
130        mDensity = displayMetrics.density;
131
132        // Calculate distance threshold for triggering a sync based on
133        // screen height.  Apply a min and max cutoff.
134        float threshold = (displayMetrics.heightPixels) / mDensity / 2.5f;
135        mDistanceToTriggerSyncDp = Math.max(
136                Math.min(threshold, MAX_DISTANCE_TO_TRIGGER_SYNC),
137                MIN_DISTANCE_TO_TRIGGER_SYNC);
138    }
139
140    protected void setActivity(ControllableActivity activity) {
141        mActivity = activity;
142    }
143
144    protected void setConversationContext(ConversationListContext convListContext) {
145        mConvListContext = convListContext;
146        mAccountPreferences = AccountPreferences.get(getContext(),
147                convListContext.account.getEmailAddress());
148    }
149
150    @Override
151    public void onBeginSwipe() {
152        mIgnoreTouchEvents = true;
153        if (mTrackingScrollMovement) {
154            cancelMovementTracking();
155        }
156    }
157
158    private void addHintTextViewIfNecessary() {
159        if (!mHasHintTextViewBeenAdded) {
160            mWindowManager.addView(mHintText, getRefreshHintTextLayoutParams());
161            mHasHintTextViewBeenAdded = true;
162        }
163    }
164
165    @Override
166    public boolean dispatchTouchEvent(MotionEvent event) {
167        // Delayed to this step because activity has to be running in order for view to be
168        // successfully added to the window manager.
169        addHintTextViewIfNecessary();
170
171        // First check for any events that can trigger end of a swipe, so we can reset
172        // mIgnoreTouchEvents back to false (it can only be set to true at beginning of swipe)
173        // via {#onBeginSwipe()} callback.
174        switch (event.getAction()) {
175            case MotionEvent.ACTION_DOWN:
176            case MotionEvent.ACTION_UP:
177            case MotionEvent.ACTION_CANCEL:
178                mIgnoreTouchEvents = false;
179        }
180
181        if (mIgnoreTouchEvents) {
182            return super.dispatchTouchEvent(event);
183        }
184
185        float y = event.getY(0);
186        switch (event.getAction()) {
187            case MotionEvent.ACTION_DOWN:
188                if (mIsSyncing) {
189                    break;
190                }
191                // Disable swipe to refresh in search results page
192                if (ConversationListContext.isSearchResult(mConvListContext)) {
193                    break;
194                }
195                // Disable swipe to refresh in CAB mode
196                if (mActivity.getSelectedSet() != null &&
197                        mActivity.getSelectedSet().size() > 0) {
198                    break;
199                }
200                // Only if we have reached the top of the list, any further scrolling
201                // can potentially trigger a sync.
202                if (mListView.getChildCount() == 0 || mListView.getChildAt(0).getTop() == 0) {
203                    startMovementTracking(y);
204                }
205                break;
206            case MotionEvent.ACTION_MOVE:
207                if (mTrackingScrollMovement) {
208                    if (mActivity.getFolderController().getFolder().isDraft()) {
209                        // Don't allow refreshing of DRAFT folders. See b/11158759
210                        LogUtils.d(LOG_TAG, "ignoring swipe to refresh on DRAFT folder");
211                        break;
212                    }
213                    if (mActivity.getFolderController().getFolder().supportsCapability(
214                            FolderCapabilities.IS_VIRTUAL)) {
215                        // Don't allow refreshing of virtual folders.
216                        LogUtils.d(LOG_TAG, "ignoring swipe to refresh on virtual folder");
217                        break;
218                    }
219                    // Sync is triggered when tap and drag distance goes over a certain threshold
220                    float verticalDistancePx = y - mTrackingScrollStartY;
221                    float verticalDistanceDp = verticalDistancePx / mDensity;
222                    if (verticalDistanceDp > mDistanceToTriggerSyncDp) {
223                        LogUtils.i(LOG_TAG, "Sync triggered from distance");
224                        triggerSync();
225                        break;
226                    }
227
228                    // Moving back up vertically should be handled the same as CANCEL / UP:
229                    float verticalDistanceFromMaxPx = mTrackingScrollMaxY - y;
230                    float verticalDistanceFromMaxDp = verticalDistanceFromMaxPx / mDensity;
231                    if (verticalDistanceFromMaxDp > DISTANCE_TO_TRIGGER_CANCEL) {
232                        cancelMovementTracking();
233                        break;
234                    }
235
236                    // Otherwise hint how much further user needs to drag to trigger sync by
237                    // expanding the sync status bar proportional to how far they have dragged.
238                    if (verticalDistanceDp < DISTANCE_TO_IGNORE) {
239                        // Ignore small movements such as tap
240                        verticalDistanceDp = 0;
241                    } else {
242                        mHintText.displaySwipeToRefresh();
243                    }
244                    setTriggerScale(mAccelerateInterpolator.getInterpolation(
245                            verticalDistanceDp/mDistanceToTriggerSyncDp));
246
247                    if (y > mTrackingScrollMaxY) {
248                        mTrackingScrollMaxY = y;
249                    }
250                }
251                break;
252            case MotionEvent.ACTION_CANCEL:
253            case MotionEvent.ACTION_UP:
254                if (mTrackingScrollMovement) {
255                    cancelMovementTracking();
256                }
257                break;
258        }
259
260        return super.dispatchTouchEvent(event);
261    }
262
263    private void startMovementTracking(float y) {
264        LogUtils.d(LOG_TAG, "Start swipe to refresh tracking");
265        mTrackingScrollMovement = true;
266        mTrackingScrollStartY = y;
267        mTrackingScrollMaxY = mTrackingScrollStartY;
268    }
269
270    private void cancelMovementTracking() {
271        if (mTrackingScrollMovement) {
272            // Shrink the status bar when user lifts finger and no sync has happened yet
273            if (mSyncTriggerBar != null) {
274                mSyncTriggerBar.animate()
275                        .scaleX(0f)
276                        .setInterpolator(mDecelerateInterpolator)
277                        .setDuration(SYNC_TRIGGER_SHRINK_DURATION_IN_MILLIS)
278                        .setListener(mSyncDismissListener)
279                        .start();
280            }
281            mTrackingScrollMovement = false;
282        }
283        mHintText.hide();
284    }
285
286    private void setTriggerScale(float scale) {
287        if (scale == 0f && mSyncTriggerBar == null) {
288            // No-op. A null trigger means it's uninitialized, and setting it to zero-scale
289            // means we're trying to reset state, so there's nothing to reset in this case.
290            return;
291        } else if (mSyncTriggerBar != null) {
292            // reset any leftover trigger visual state
293            mSyncTriggerBar.animate().cancel();
294            mSyncTriggerBar.setVisibility(VISIBLE);
295        }
296        ensureProgressBars();
297        mSyncTriggerBar.setScaleX(scale);
298    }
299
300    private void ensureProgressBars() {
301        if (mSyncTriggerBar == null || mSyncProgressBar == null) {
302            final LayoutInflater inflater = LayoutInflater.from(getContext());
303            inflater.inflate(R.layout.conversation_list_progress, this, true /* attachToRoot */);
304            mSyncTriggerBar = findViewById(R.id.sync_trigger);
305            mSyncProgressBar = findViewById(R.id.progress);
306        }
307    }
308
309    private void triggerSync() {
310        ensureProgressBars();
311        mSyncTriggerBar.setVisibility(View.GONE);
312
313        Analytics.getInstance().sendEvent(Analytics.EVENT_CATEGORY_MENU_ITEM, "swipe_refresh", null,
314                0);
315
316        // This will call back to showSyncStatusBar():
317        mActivity.getFolderController().requestFolderRefresh();
318
319        // Any continued dragging after this should have no effect
320        mTrackingScrollMovement = false;
321
322        mHintText.displayCheckingForMailAndHideAfterDelay();
323    }
324
325    protected void showSyncStatusBar() {
326        if (!mIsSyncing) {
327            mIsSyncing = true;
328
329            LogUtils.i(LOG_TAG, "ConversationListView show sync status bar");
330            ensureProgressBars();
331            mSyncTriggerBar.setVisibility(GONE);
332            mSyncProgressBar.setVisibility(VISIBLE);
333            mSyncProgressBar.setAlpha(1f);
334
335            showToastIfSyncIsOff();
336        }
337    }
338
339    // If sync is turned off on this device or account, remind the user with a toast.
340    private void showToastIfSyncIsOff() {
341        final int reasonSyncOff = ConversationSyncDisabledTipView.calculateReasonSyncOff(
342                mMailPrefs, mConvListContext.account, mAccountPreferences);
343        switch (reasonSyncOff) {
344            case ReasonSyncOff.AUTO_SYNC_OFF:
345                // TODO: make this an actionable toast, tapping on it goes to Settings
346                int num = mMailPrefs.getNumOfDismissesForAutoSyncOff();
347                if (num > 0 && num <= MAX_NUM_OF_SYNC_TOASTS) {
348                    Toast.makeText(getContext(), R.string.auto_sync_off, Toast.LENGTH_SHORT)
349                            .show();
350                    mMailPrefs.incNumOfDismissesForAutoSyncOff();
351                }
352                break;
353            case ReasonSyncOff.ACCOUNT_SYNC_OFF:
354                // TODO: make this an actionable toast, tapping on it goes to Settings
355                num = mAccountPreferences.getNumOfDismissesForAccountSyncOff();
356                if (num > 0 && num <= MAX_NUM_OF_SYNC_TOASTS) {
357                    Toast.makeText(getContext(), R.string.account_sync_off, Toast.LENGTH_SHORT)
358                            .show();
359                    mAccountPreferences.incNumOfDismissesForAccountSyncOff();
360                }
361                break;
362        }
363    }
364
365    protected void onSyncFinished() {
366        // onSyncFinished() can get called several times as result of folder updates that maybe
367        // or may not be related to sync.
368        if (mIsSyncing) {
369            LogUtils.i(LOG_TAG, "ConversationListView hide sync status bar");
370            // Hide both the sync progress bar and sync trigger bar
371            mSyncProgressBar.animate().alpha(0f)
372                    .setDuration(SYNC_STATUS_BAR_FADE_DURATION_IN_MILLIS)
373                    .setListener(mSyncDismissListener);
374            mSyncTriggerBar.setVisibility(GONE);
375            // Hide the "Checking for mail" text in action bar if it isn't hidden already:
376            mHintText.hide();
377            mIsSyncing = false;
378        }
379    }
380
381    @Override
382    protected void onDetachedFromWindow() {
383        if (mHasHintTextViewBeenAdded) {
384            try {
385                mWindowManager.removeView(mHintText);
386            } catch (IllegalArgumentException e) {
387                // Have seen this happen on occasion during orientation change.
388            }
389        }
390    }
391
392    private WindowManager.LayoutParams getRefreshHintTextLayoutParams() {
393        // Create the "Swipe down to refresh" text view that covers the action bar.
394        Rect rect= new Rect();
395        Window window = mActivity.getWindow();
396        window.getDecorView().getWindowVisibleDisplayFrame(rect);
397        int statusBarHeight = rect.top;
398
399        final TypedArray actionBarSize = ((Activity) mActivity).obtainStyledAttributes(
400                new int[]{android.R.attr.actionBarSize});
401        int actionBarHeight = actionBarSize.getDimensionPixelSize(0, 0);
402        actionBarSize.recycle();
403
404        WindowManager.LayoutParams params = new WindowManager.LayoutParams(
405                WindowManager.LayoutParams.MATCH_PARENT,
406                actionBarHeight,
407                WindowManager.LayoutParams.TYPE_APPLICATION_PANEL,
408                WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
409                PixelFormat.TRANSLUCENT);
410        params.gravity = Gravity.TOP;
411        params.x = 0;
412        params.y = statusBarHeight;
413        return params;
414    }
415
416    /**
417     * A text view that covers the entire action bar, used for displaying
418     * "Swipe down to refresh" hint text if user has initiated a downward swipe.
419     */
420    protected static class HintText extends FrameLayout {
421
422        private static final int NONE = 0;
423        private static final int SWIPE_TO_REFRESH = 1;
424        private static final int CHECKING_FOR_MAIL = 2;
425
426        // Can be one of NONE, SWIPE_TO_REFRESH, CHECKING_FOR_MAIL
427        private int mDisplay;
428
429        private final TextView mTextView;
430
431        private final Interpolator mDecelerateInterpolator = new DecelerateInterpolator(1.5f);
432        private final Interpolator mAccelerateInterpolator = new AccelerateInterpolator(1.5f);
433
434        private final Runnable mHideHintTextRunnable = new Runnable() {
435            @Override
436            public void run() {
437                hide();
438            }
439        };
440        private final Runnable mSetVisibilityGoneRunnable = new Runnable() {
441            @Override
442            public void run() {
443                setVisibility(View.GONE);
444            }
445        };
446
447        public HintText(final Context context) {
448            this(context, null);
449        }
450
451        public HintText(final Context context, final AttributeSet attrs) {
452            this(context, attrs, -1);
453        }
454
455        public HintText(final Context context, final AttributeSet attrs, final int defStyle) {
456            super(context, attrs, defStyle);
457
458            final LayoutInflater factory = LayoutInflater.from(context);
459            factory.inflate(R.layout.swipe_to_refresh, this);
460
461            mTextView = (TextView) findViewById(R.id.swipe_text);
462
463            mDisplay = NONE;
464            setVisibility(View.GONE);
465
466            // Set background color to be same as action bar color
467            final int actionBarRes = Utils.getActionBarBackgroundResource(context);
468            setBackgroundResource(actionBarRes);
469        }
470
471        private void displaySwipeToRefresh() {
472            if (mDisplay != SWIPE_TO_REFRESH) {
473                mTextView.setText(getResources().getText(R.string.swipe_down_to_refresh));
474                // Covers the current action bar:
475                setVisibility(View.VISIBLE);
476                setAlpha(1f);
477                // Animate text sliding down onto action bar:
478                mTextView.setY(-mTextView.getHeight());
479                mTextView.animate().y(0)
480                        .setInterpolator(mDecelerateInterpolator)
481                        .setDuration(SWIPE_TEXT_APPEAR_DURATION_IN_MILLIS);
482                mDisplay = SWIPE_TO_REFRESH;
483            }
484        }
485
486        private void displayCheckingForMailAndHideAfterDelay() {
487            mTextView.setText(getResources().getText(R.string.checking_for_mail));
488            setVisibility(View.VISIBLE);
489            mDisplay = CHECKING_FOR_MAIL;
490            postDelayed(mHideHintTextRunnable, SHOW_CHECKING_FOR_MAIL_DURATION_IN_MILLIS);
491        }
492
493        private void hide() {
494            if (mDisplay != NONE) {
495                // Animate text sliding up leaving behind a blank action bar
496                mTextView.animate().y(-mTextView.getHeight())
497                        .setInterpolator(mAccelerateInterpolator)
498                        .setDuration(SWIPE_TEXT_APPEAR_DURATION_IN_MILLIS)
499                        .start();
500                animate().alpha(0f)
501                        .setDuration(SWIPE_TEXT_APPEAR_DURATION_IN_MILLIS);
502                postDelayed(mSetVisibilityGoneRunnable, SWIPE_TEXT_APPEAR_DURATION_IN_MILLIS);
503                mDisplay = NONE;
504            }
505        }
506    }
507}
508