Home | History | Annotate | Download | only in ui
      1 /*
      2  * Copyright (C) 2012 Google Inc.
      3  * Licensed to The Android Open Source Project.
      4  *
      5  * Licensed under the Apache License, Version 2.0 (the "License");
      6  * you may not use this file except in compliance with the License.
      7  * You may obtain a copy of the License at
      8  *
      9  *      http://www.apache.org/licenses/LICENSE-2.0
     10  *
     11  * Unless required by applicable law or agreed to in writing, software
     12  * distributed under the License is distributed on an "AS IS" BASIS,
     13  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     14  * See the License for the specific language governing permissions and
     15  * limitations under the License.
     16  */
     17 
     18 package com.android.mail.ui;
     19 
     20 import android.app.Activity;
     21 import android.app.ListFragment;
     22 import android.app.LoaderManager;
     23 import android.content.Context;
     24 import android.content.res.Resources;
     25 import android.database.DataSetObserver;
     26 import android.os.Bundle;
     27 import android.os.Handler;
     28 import android.os.Parcelable;
     29 import android.text.format.DateUtils;
     30 import android.view.LayoutInflater;
     31 import android.view.View;
     32 import android.view.ViewGroup;
     33 import android.view.ViewGroup.MarginLayoutParams;
     34 import android.widget.AdapterView;
     35 import android.widget.AdapterView.OnItemLongClickListener;
     36 import android.widget.ListView;
     37 import android.widget.TextView;
     38 
     39 import com.android.mail.ConversationListContext;
     40 import com.android.mail.R;
     41 import com.android.mail.analytics.Analytics;
     42 import com.android.mail.browse.ConversationCursor;
     43 import com.android.mail.browse.ConversationItemView;
     44 import com.android.mail.browse.ConversationItemViewModel;
     45 import com.android.mail.browse.ConversationListFooterView;
     46 import com.android.mail.browse.ToggleableItem;
     47 import com.android.mail.providers.Account;
     48 import com.android.mail.providers.AccountObserver;
     49 import com.android.mail.providers.Conversation;
     50 import com.android.mail.providers.Folder;
     51 import com.android.mail.providers.FolderObserver;
     52 import com.android.mail.providers.Settings;
     53 import com.android.mail.providers.UIProvider;
     54 import com.android.mail.providers.UIProvider.AccountCapabilities;
     55 import com.android.mail.providers.UIProvider.ConversationListIcon;
     56 import com.android.mail.providers.UIProvider.FolderCapabilities;
     57 import com.android.mail.providers.UIProvider.FolderType;
     58 import com.android.mail.providers.UIProvider.Swipe;
     59 import com.android.mail.ui.AnimatedAdapter.ConversationListListener;
     60 import com.android.mail.ui.SwipeableListView.ListItemSwipedListener;
     61 import com.android.mail.ui.SwipeableListView.ListItemsRemovedListener;
     62 import com.android.mail.ui.ViewMode.ModeChangeListener;
     63 import com.android.mail.utils.LogTag;
     64 import com.android.mail.utils.LogUtils;
     65 import com.android.mail.utils.Utils;
     66 import com.google.common.collect.ImmutableList;
     67 
     68 import java.util.Collection;
     69 import java.util.List;
     70 
     71 /**
     72  * The conversation list UI component.
     73  */
     74 public final class ConversationListFragment extends ListFragment implements
     75         OnItemLongClickListener, ModeChangeListener, ListItemSwipedListener {
     76     /** Key used to pass data to {@link ConversationListFragment}. */
     77     private static final String CONVERSATION_LIST_KEY = "conversation-list";
     78     /** Key used to keep track of the scroll state of the list. */
     79     private static final String LIST_STATE_KEY = "list-state";
     80 
     81     private static final String LOG_TAG = LogTag.getLogTag();
     82     /** Key used to save the ListView choice mode, since ListView doesn't save it automatically! */
     83     private static final String CHOICE_MODE_KEY = "choice-mode-key";
     84 
     85     // True if we are on a tablet device
     86     private static boolean mTabletDevice;
     87 
     88     /**
     89      * Frequency of update of timestamps. Initialized in
     90      * {@link #onCreate(Bundle)} and final afterwards.
     91      */
     92     private static int TIMESTAMP_UPDATE_INTERVAL = 0;
     93 
     94     private static long NO_NEW_MESSAGE_DURATION = 1 * DateUtils.SECOND_IN_MILLIS;
     95 
     96     private ControllableActivity mActivity;
     97 
     98     // Control state.
     99     private ConversationListCallbacks mCallbacks;
    100 
    101     private final Handler mHandler = new Handler();
    102 
    103     private ConversationListView mConversationListView;
    104 
    105     // The internal view objects.
    106     private SwipeableListView mListView;
    107 
    108     private TextView mSearchResultCountTextView;
    109     private TextView mSearchStatusTextView;
    110 
    111     private View mSearchStatusView;
    112 
    113     /**
    114      * Current Account being viewed
    115      */
    116     private Account mAccount;
    117     /**
    118      * Current folder being viewed.
    119      */
    120     private Folder mFolder;
    121 
    122     /**
    123      * A simple method to update the timestamps of conversations periodically.
    124      */
    125     private Runnable mUpdateTimestampsRunnable = null;
    126 
    127     private ConversationListContext mViewContext;
    128 
    129     private AnimatedAdapter mListAdapter;
    130 
    131     private ConversationListFooterView mFooterView;
    132     private View mEmptyView;
    133     private ErrorListener mErrorListener;
    134     private FolderObserver mFolderObserver;
    135     private DataSetObserver mConversationCursorObserver;
    136 
    137     private ConversationSelectionSet mSelectedSet;
    138     private final AccountObserver mAccountObserver = new AccountObserver() {
    139         @Override
    140         public void onChanged(Account newAccount) {
    141             mAccount = newAccount;
    142             setSwipeAction();
    143         }
    144     };
    145     private ConversationUpdater mUpdater;
    146     /** Hash of the Conversation Cursor we last obtained from the controller. */
    147     private int mConversationCursorHash;
    148 
    149     /** Duration, in milliseconds, of the CAB mode (peek icon) animation. */
    150     private static long sSelectionModeAnimationDuration = -1;
    151     /** The time at which we last exited CAB mode. */
    152     private long mSelectionModeExitedTimestamp = -1;
    153 
    154     /**
    155      * If <code>true</code>, we have restored (or attempted to restore) the list's scroll position
    156      * from when we were last on this conversation list.
    157      */
    158     private boolean mScrollPositionRestored = false;
    159 
    160     /**
    161      * Constructor needs to be public to handle orientation changes and activity
    162      * lifecycle events.
    163      */
    164     public ConversationListFragment() {
    165         super();
    166     }
    167 
    168     private class ConversationCursorObserver extends DataSetObserver {
    169         @Override
    170         public void onChanged() {
    171             onConversationListStatusUpdated();
    172         }
    173     }
    174 
    175     /**
    176      * Creates a new instance of {@link ConversationListFragment}, initialized
    177      * to display conversation list context.
    178      */
    179     public static ConversationListFragment newInstance(ConversationListContext viewContext) {
    180         final ConversationListFragment fragment = new ConversationListFragment();
    181         final Bundle args = new Bundle(1);
    182         args.putBundle(CONVERSATION_LIST_KEY, viewContext.toBundle());
    183         fragment.setArguments(args);
    184         return fragment;
    185     }
    186 
    187     /**
    188      * Show the header if the current conversation list is showing search
    189      * results.
    190      */
    191     void configureSearchResultHeader() {
    192         if (mActivity == null) {
    193             return;
    194         }
    195         // Only show the header if the context is for a search result
    196         final Resources res = getResources();
    197         final boolean showHeader = ConversationListContext.isSearchResult(mViewContext);
    198         // TODO(viki): This code contains intimate understanding of the view.
    199         // Much of this logic
    200         // needs to reside in a separate class that handles the text view in
    201         // isolation. Then,
    202         // that logic can be reused in other fragments.
    203         if (showHeader) {
    204             mSearchStatusTextView.setText(res.getString(R.string.search_results_searching_header));
    205             // Initially reset the count
    206             mSearchResultCountTextView.setText("");
    207         }
    208         mSearchStatusView.setVisibility(showHeader ? View.VISIBLE : View.GONE);
    209         int marginTop = showHeader ? (int) res.getDimension(R.dimen.notification_view_height) : 0;
    210         MarginLayoutParams layoutParams = (MarginLayoutParams) mListView.getLayoutParams();
    211         layoutParams.topMargin = marginTop;
    212         mListView.setLayoutParams(layoutParams);
    213     }
    214 
    215     /**
    216      * Show the header if the current conversation list is showing search
    217      * results.
    218      */
    219     private void updateSearchResultHeader(int count) {
    220         if (mActivity == null) {
    221             return;
    222         }
    223         // Only show the header if the context is for a search result
    224         final Resources res = getResources();
    225         final boolean showHeader = ConversationListContext.isSearchResult(mViewContext);
    226         if (showHeader) {
    227             mSearchStatusTextView.setText(res.getString(R.string.search_results_header));
    228             mSearchResultCountTextView
    229                     .setText(res.getString(R.string.search_results_loaded, count));
    230         }
    231     }
    232 
    233     /**
    234      * Initializes all internal state for a rendering.
    235      */
    236     private void initializeUiForFirstDisplay() {
    237         // TODO(mindyp): find some way to make the notification container more
    238         // re-usable.
    239         // TODO(viki): refactor according to comment in
    240         // configureSearchResultHandler()
    241         mSearchStatusView = mActivity.findViewById(R.id.search_status_view);
    242         mSearchStatusTextView = (TextView) mActivity.findViewById(R.id.search_status_text_view);
    243         mSearchResultCountTextView = (TextView) mActivity
    244                 .findViewById(R.id.search_result_count_view);
    245     }
    246 
    247     @Override
    248     public void onActivityCreated(Bundle savedState) {
    249         super.onActivityCreated(savedState);
    250 
    251         if (sSelectionModeAnimationDuration < 0) {
    252             sSelectionModeAnimationDuration = getResources().getInteger(
    253                     R.integer.conv_item_view_cab_anim_duration);
    254         }
    255 
    256         // Strictly speaking, we get back an android.app.Activity from
    257         // getActivity. However, the
    258         // only activity creating a ConversationListContext is a MailActivity
    259         // which is of type
    260         // ControllableActivity, so this cast should be safe. If this cast
    261         // fails, some other
    262         // activity is creating ConversationListFragments. This activity must be
    263         // of type
    264         // ControllableActivity.
    265         final Activity activity = getActivity();
    266         if (!(activity instanceof ControllableActivity)) {
    267             LogUtils.e(LOG_TAG, "ConversationListFragment expects only a ControllableActivity to"
    268                     + "create it. Cannot proceed.");
    269         }
    270         mActivity = (ControllableActivity) activity;
    271         // Since we now have a controllable activity, load the account from it,
    272         // and register for
    273         // future account changes.
    274         mAccount = mAccountObserver.initialize(mActivity.getAccountController());
    275         mCallbacks = mActivity.getListHandler();
    276         mErrorListener = mActivity.getErrorListener();
    277         // Start off with the current state of the folder being viewed.
    278         Context activityContext = mActivity.getActivityContext();
    279         mFooterView = (ConversationListFooterView) LayoutInflater.from(
    280                 activityContext).inflate(R.layout.conversation_list_footer_view,
    281                 null);
    282         mFooterView.setClickListener(mActivity);
    283         mConversationListView.setActivity(mActivity);
    284         final ConversationCursor conversationCursor = getConversationListCursor();
    285         final LoaderManager manager = getLoaderManager();
    286 
    287         // TODO: These special views are always created, doesn't matter whether they will
    288         // be shown or not, as we add more views this will get more expensive. Given these are
    289         // tips that are only shown once to the user, we should consider creating these on demand.
    290         final ConversationListHelper helper = mActivity.getConversationListHelper();
    291         final List<ConversationSpecialItemView> specialItemViews = helper != null ?
    292                 ImmutableList.copyOf(helper.makeConversationListSpecialViews(
    293                         activity, mActivity, mAccount))
    294                 : null;
    295         if (specialItemViews != null) {
    296             // Attach to the LoaderManager
    297             for (final ConversationSpecialItemView view : specialItemViews) {
    298                 view.bindFragment(manager, savedState);
    299             }
    300         }
    301 
    302         mListAdapter = new AnimatedAdapter(mActivity.getApplicationContext(), conversationCursor,
    303                 mActivity.getSelectedSet(), mActivity, mConversationListListener, mListView,
    304                 specialItemViews);
    305         mListAdapter.addFooter(mFooterView);
    306         mListView.setAdapter(mListAdapter);
    307         mSelectedSet = mActivity.getSelectedSet();
    308         mListView.setSelectionSet(mSelectedSet);
    309         mListAdapter.setFooterVisibility(false);
    310         mFolderObserver = new FolderObserver(){
    311             @Override
    312             public void onChanged(Folder newFolder) {
    313                 onFolderUpdated(newFolder);
    314             }
    315         };
    316         mFolderObserver.initialize(mActivity.getFolderController());
    317         mConversationCursorObserver = new ConversationCursorObserver();
    318         mUpdater = mActivity.getConversationUpdater();
    319         mUpdater.registerConversationListObserver(mConversationCursorObserver);
    320         mTabletDevice = Utils.useTabletUI(mActivity.getApplicationContext().getResources());
    321         initializeUiForFirstDisplay();
    322         configureSearchResultHeader();
    323         // The onViewModeChanged callback doesn't get called when the mode
    324         // object is created, so
    325         // force setting the mode manually this time around.
    326         onViewModeChanged(mActivity.getViewMode().getMode());
    327         mActivity.getViewMode().addListener(this);
    328 
    329         if (mActivity.isFinishing()) {
    330             // Activity is finishing, just bail.
    331             return;
    332         }
    333         mConversationCursorHash = (conversationCursor == null) ? 0 : conversationCursor.hashCode();
    334         // Belt and suspenders here; make sure we do any necessary sync of the
    335         // ConversationCursor
    336         if (conversationCursor != null && conversationCursor.isRefreshReady()) {
    337             conversationCursor.sync();
    338         }
    339 
    340         // On a phone we never highlight a conversation, so the default is to select none.
    341         // On a tablet, we highlight a SINGLE conversation in landscape conversation view.
    342         int choice = getDefaultChoiceMode(mTabletDevice);
    343         if (savedState != null) {
    344             // Restore the choice mode if it was set earlier, or NONE if creating a fresh view.
    345             // Choice mode here represents the current conversation only. CAB mode does not rely on
    346             // the platform: checked state is a local variable {@link ConversationItemView#mChecked}
    347             choice = savedState.getInt(CHOICE_MODE_KEY, choice);
    348             if (savedState.containsKey(LIST_STATE_KEY)) {
    349                 // TODO: find a better way to unset the selected item when restoring
    350                 mListView.clearChoices();
    351             }
    352         }
    353         setChoiceMode(choice);
    354 
    355         // Show list and start loading list.
    356         showList();
    357         ToastBarOperation pendingOp = mActivity.getPendingToastOperation();
    358         if (pendingOp != null) {
    359             // Clear the pending operation
    360             mActivity.setPendingToastOperation(null);
    361             mActivity.onUndoAvailable(pendingOp);
    362         }
    363     }
    364 
    365     /**
    366      * Returns the default choice mode for the list based on whether the list is displayed on tablet
    367      * or not.
    368      * @param isTablet
    369      * @return
    370      */
    371     private final static int getDefaultChoiceMode(boolean isTablet) {
    372         return isTablet ? ListView.CHOICE_MODE_SINGLE : ListView.CHOICE_MODE_NONE;
    373     }
    374 
    375     public AnimatedAdapter getAnimatedAdapter() {
    376         return mListAdapter;
    377     }
    378 
    379     @Override
    380     public void onCreate(Bundle savedState) {
    381         super.onCreate(savedState);
    382 
    383         // Initialize fragment constants from resources
    384         final Resources res = getResources();
    385         TIMESTAMP_UPDATE_INTERVAL = res.getInteger(R.integer.timestamp_update_interval);
    386         mUpdateTimestampsRunnable = new Runnable() {
    387             @Override
    388             public void run() {
    389                 mListView.invalidateViews();
    390                 mHandler.postDelayed(mUpdateTimestampsRunnable, TIMESTAMP_UPDATE_INTERVAL);
    391             }
    392         };
    393 
    394         // Get the context from the arguments
    395         final Bundle args = getArguments();
    396         mViewContext = ConversationListContext.forBundle(args.getBundle(CONVERSATION_LIST_KEY));
    397         mAccount = mViewContext.account;
    398 
    399         setRetainInstance(false);
    400     }
    401 
    402     @Override
    403     public String toString() {
    404         final String s = super.toString();
    405         if (mViewContext == null) {
    406             return s;
    407         }
    408         final StringBuilder sb = new StringBuilder(s);
    409         sb.setLength(sb.length() - 1);
    410         sb.append(" mListAdapter=");
    411         sb.append(mListAdapter);
    412         sb.append(" folder=");
    413         sb.append(mViewContext.folder);
    414         sb.append("}");
    415         return sb.toString();
    416     }
    417 
    418     @Override
    419     public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedState) {
    420         View rootView = inflater.inflate(R.layout.conversation_list, null);
    421         mEmptyView = rootView.findViewById(R.id.empty_view);
    422         mConversationListView =
    423                 (ConversationListView) rootView.findViewById(R.id.conversation_list);
    424         mConversationListView.setConversationContext(mViewContext);
    425         mListView = (SwipeableListView) rootView.findViewById(android.R.id.list);
    426         mListView.setHeaderDividersEnabled(false);
    427         mListView.setOnItemLongClickListener(this);
    428         mListView.enableSwipe(mAccount.supportsCapability(AccountCapabilities.UNDO));
    429         mListView.setSwipedListener(this);
    430 
    431         if (savedState != null && savedState.containsKey(LIST_STATE_KEY)) {
    432             mListView.onRestoreInstanceState(savedState.getParcelable(LIST_STATE_KEY));
    433         }
    434 
    435         return rootView;
    436     }
    437 
    438     /**
    439      * Sets the choice mode of the list view
    440      * @param choiceMode ListView#
    441      */
    442     private final void setChoiceMode(int choiceMode) {
    443         mListView.setChoiceMode(choiceMode);
    444     }
    445 
    446     /**
    447      * Tell the list to select nothing.
    448      */
    449     public final void setChoiceNone() {
    450         // On a phone, the default choice mode is already none, so nothing to do.
    451         if (!mTabletDevice) {
    452             return;
    453         }
    454         clearChoicesAndActivated();
    455         setChoiceMode(ListView.CHOICE_MODE_NONE);
    456     }
    457 
    458     /**
    459      * Tell the list to get out of selecting none.
    460      */
    461     public final void revertChoiceMode() {
    462         // On a phone, the default choice mode is always none, so nothing to do.
    463         if (!mTabletDevice) {
    464             return;
    465         }
    466         setChoiceMode(getDefaultChoiceMode(mTabletDevice));
    467     }
    468 
    469     @Override
    470     public void onDestroy() {
    471         super.onDestroy();
    472     }
    473 
    474     @Override
    475     public void onDestroyView() {
    476 
    477         // Clear the list's adapter
    478         mListAdapter.destroy();
    479         mListView.setAdapter(null);
    480 
    481         mActivity.getViewMode().removeListener(this);
    482         if (mFolderObserver != null) {
    483             mFolderObserver.unregisterAndDestroy();
    484             mFolderObserver = null;
    485         }
    486         if (mConversationCursorObserver != null) {
    487             mUpdater.unregisterConversationListObserver(mConversationCursorObserver);
    488             mConversationCursorObserver = null;
    489         }
    490         mAccountObserver.unregisterAndDestroy();
    491         getAnimatedAdapter().cleanup();
    492         super.onDestroyView();
    493     }
    494 
    495     /**
    496      * There are three binary variables, which determine what we do with a
    497      * message. checkbEnabled: Whether check boxes are enabled or not (forced
    498      * true on tablet) cabModeOn: Whether CAB mode is currently on or not.
    499      * pressType: long or short tap (There is a third possibility: phone or
    500      * tablet, but they have <em>identical</em> behavior) The matrix of
    501      * possibilities is:
    502      * <p>
    503      * Long tap: Always toggle selection of conversation. If CAB mode is not
    504      * started, then start it.
    505      * <pre>
    506      *              | Checkboxes | No Checkboxes
    507      *    ----------+------------+---------------
    508      *    CAB mode  |   Select   |     Select
    509      *    List mode |   Select   |     Select
    510      *
    511      * </pre>
    512      *
    513      * Reference: http://b/issue?id=6392199
    514      * <p>
    515      * {@inheritDoc}
    516      */
    517     @Override
    518     public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) {
    519         // Ignore anything that is not a conversation item. Could be a footer.
    520         if (!(view instanceof ConversationItemView)) {
    521             return false;
    522         }
    523         return ((ConversationItemView) view).toggleSelectedStateOrBeginDrag();
    524     }
    525 
    526     /**
    527      * See the comment for
    528      * {@link #onItemLongClick(AdapterView, View, int, long)}.
    529      * <p>
    530      * Short tap behavior:
    531      *
    532      * <pre>
    533      *              | Checkboxes | No Checkboxes
    534      *    ----------+------------+---------------
    535      *    CAB mode  |    Peek    |     Select
    536      *    List mode |    Peek    |      Peek
    537      * </pre>
    538      *
    539      * Reference: http://b/issue?id=6392199
    540      * <p>
    541      * {@inheritDoc}
    542      */
    543     @Override
    544     public void onListItemClick(ListView l, View view, int position, long id) {
    545         if (view instanceof ToggleableItem) {
    546             final boolean showSenderImage =
    547                     (mAccount.settings.convListIcon == ConversationListIcon.SENDER_IMAGE);
    548             final boolean inCabMode = !mSelectedSet.isEmpty();
    549             if (!showSenderImage && inCabMode) {
    550                 ((ToggleableItem) view).toggleSelectedState();
    551             } else {
    552                 if (inCabMode) {
    553                     // this is a peek.
    554                     Analytics.getInstance().sendEvent("peek", null, null, mSelectedSet.size());
    555                 }
    556                 viewConversation(position);
    557             }
    558         } else {
    559             // Ignore anything that is not a conversation item. Could be a footer.
    560             // If we are using a keyboard, the highlighted item is the parent;
    561             // otherwise, this is a direct call from the ConverationItemView
    562             return;
    563         }
    564         // When a new list item is clicked, commit any existing leave behind
    565         // items. Wait until we have opened the desired conversation to cause
    566         // any position changes.
    567         commitDestructiveActions(Utils.useTabletUI(mActivity.getActivityContext().getResources()));
    568     }
    569 
    570     @Override
    571     public void onResume() {
    572         super.onResume();
    573 
    574         final ConversationCursor conversationCursor = getConversationListCursor();
    575         if (conversationCursor != null) {
    576             conversationCursor.handleNotificationActions();
    577 
    578             restoreLastScrolledPosition();
    579         }
    580 
    581         mSelectedSet.addObserver(mConversationSetObserver);
    582     }
    583 
    584     @Override
    585     public void onPause() {
    586         super.onPause();
    587 
    588         mSelectedSet.removeObserver(mConversationSetObserver);
    589 
    590         saveLastScrolledPosition();
    591     }
    592 
    593     @Override
    594     public void onSaveInstanceState(Bundle outState) {
    595         super.onSaveInstanceState(outState);
    596         if (mListView != null) {
    597             outState.putParcelable(LIST_STATE_KEY, mListView.onSaveInstanceState());
    598             outState.putInt(CHOICE_MODE_KEY, mListView.getChoiceMode());
    599         }
    600 
    601         if (mListAdapter != null) {
    602             mListAdapter.saveSpecialItemInstanceState(outState);
    603         }
    604     }
    605 
    606     @Override
    607     public void onStart() {
    608         super.onStart();
    609         mHandler.postDelayed(mUpdateTimestampsRunnable, TIMESTAMP_UPDATE_INTERVAL);
    610         Analytics.getInstance().sendView(getClass().getName());
    611     }
    612 
    613     @Override
    614     public void onStop() {
    615         super.onStop();
    616         mHandler.removeCallbacks(mUpdateTimestampsRunnable);
    617     }
    618 
    619     @Override
    620     public void onViewModeChanged(int newMode) {
    621         if (mTabletDevice) {
    622             if (ViewMode.isListMode(newMode)) {
    623                 // There are no selected conversations when in conversation list mode.
    624                 clearChoicesAndActivated();
    625             }
    626         }
    627         if (mFooterView != null) {
    628             mFooterView.onViewModeChanged(newMode);
    629         }
    630     }
    631 
    632     public boolean isAnimating() {
    633         final AnimatedAdapter adapter = getAnimatedAdapter();
    634         return (adapter != null && adapter.isAnimating()) ||
    635                 (mListView != null && mListView.isScrolling());
    636     }
    637 
    638     private void clearChoicesAndActivated() {
    639         final int currentSelected = mListView.getCheckedItemPosition();
    640         if (currentSelected != ListView.INVALID_POSITION) {
    641             mListView.setItemChecked(mListView.getCheckedItemPosition(), false);
    642         }
    643     }
    644 
    645     /**
    646      * Handles a request to show a new conversation list, either from a search
    647      * query or for viewing a folder. This will initiate a data load, and hence
    648      * must be called on the UI thread.
    649      */
    650     private void showList() {
    651         mListView.setEmptyView(null);
    652         onFolderUpdated(mActivity.getFolderController().getFolder());
    653         onConversationListStatusUpdated();
    654     }
    655 
    656     /**
    657      * View the message at the given position.
    658      *
    659      * @param position The position of the conversation in the list (as opposed to its position
    660      *        in the cursor)
    661      */
    662     private void viewConversation(final int position) {
    663         LogUtils.d(LOG_TAG, "ConversationListFragment.viewConversation(%d)", position);
    664 
    665         final ConversationCursor cursor =
    666                 (ConversationCursor) getAnimatedAdapter().getItem(position);
    667 
    668         if (cursor == null) {
    669             LogUtils.e(LOG_TAG,
    670                     "unable to open conv at cursor pos=%s cursor=%s getPositionOffset=%s",
    671                     position, cursor, getAnimatedAdapter().getPositionOffset(position));
    672             return;
    673         }
    674 
    675         final Conversation conv = cursor.getConversation();
    676         /*
    677          * The cursor position may be different than the position method parameter because of
    678          * special views in the list.
    679          */
    680         conv.position = cursor.getPosition();
    681         setSelected(conv.position, true);
    682         mCallbacks.onConversationSelected(conv, false /* inLoaderCallbacks */);
    683     }
    684 
    685     private final ConversationListListener mConversationListListener =
    686             new ConversationListListener() {
    687         @Override
    688         public boolean isExitingSelectionMode() {
    689             return System.currentTimeMillis() <
    690                     (mSelectionModeExitedTimestamp + sSelectionModeAnimationDuration);
    691         }
    692     };
    693 
    694     /**
    695      * Sets the selected conversation to the position given here.
    696      * @param cursorPosition The position of the conversation in the cursor (as opposed to
    697      * in the list)
    698      * @param different if the currently selected conversation is different from the one provided
    699      * here.  This is a difference in conversations, not a difference in positions. For example, a
    700      * conversation at position 2 can move to position 4 as a result of new mail.
    701      */
    702     public void setSelected(final int cursorPosition, boolean different) {
    703         if (mListView.getChoiceMode() == ListView.CHOICE_MODE_NONE) {
    704             return;
    705         }
    706 
    707         final int position =
    708                 cursorPosition + getAnimatedAdapter().getPositionOffset(cursorPosition);
    709 
    710         setRawSelected(position, different);
    711     }
    712 
    713     /**
    714      * Sets the selected conversation to the position given here.
    715      * @param position The position of the item in the list
    716      * @param different if the currently selected conversation is different from the one provided
    717      * here.  This is a difference in conversations, not a difference in positions. For example, a
    718      * conversation at position 2 can move to position 4 as a result of new mail.
    719      */
    720     public void setRawSelected(final int position, final boolean different) {
    721         if (mListView.getChoiceMode() == ListView.CHOICE_MODE_NONE) {
    722             return;
    723         }
    724 
    725         if (different) {
    726             mListView.smoothScrollToPosition(position);
    727         }
    728         mListView.setItemChecked(position, true);
    729     }
    730 
    731     /**
    732      * Returns the cursor associated with the conversation list.
    733      * @return
    734      */
    735     private ConversationCursor getConversationListCursor() {
    736         return mCallbacks != null ? mCallbacks.getConversationListCursor() : null;
    737     }
    738 
    739     /**
    740      * Request a refresh of the list. No sync is carried out and none is
    741      * promised.
    742      */
    743     public void requestListRefresh() {
    744         mListAdapter.notifyDataSetChanged();
    745     }
    746 
    747     /**
    748      * Change the UI to delete the conversations provided and then call the
    749      * {@link DestructiveAction} provided here <b>after</b> the UI has been
    750      * updated.
    751      * @param conversations
    752      * @param action
    753      */
    754     public void requestDelete(int actionId, final Collection<Conversation> conversations,
    755             final DestructiveAction action) {
    756         for (Conversation conv : conversations) {
    757             conv.localDeleteOnUpdate = true;
    758         }
    759         final ListItemsRemovedListener listener = new ListItemsRemovedListener() {
    760             @Override
    761             public void onListItemsRemoved() {
    762                 action.performAction();
    763             }
    764         };
    765         final SwipeableListView listView = (SwipeableListView) getListView();
    766         if (listView.getSwipeAction() == actionId) {
    767             if (!listView.destroyItems(conversations, listener)) {
    768                 // The listView failed to destroy the items, perform the action manually
    769                 LogUtils.e(LOG_TAG, "ConversationListFragment.requestDelete: " +
    770                         "listView failed to destroy items.");
    771                 action.performAction();
    772             }
    773             return;
    774         }
    775         // Delete the local delete items (all for now) and when done,
    776         // update...
    777         mListAdapter.delete(conversations, listener);
    778     }
    779 
    780     public void onFolderUpdated(Folder folder) {
    781         mFolder = folder;
    782         setSwipeAction();
    783         if (mFolder == null) {
    784             return;
    785         }
    786         mListAdapter.setFolder(mFolder);
    787         mFooterView.setFolder(mFolder);
    788         if (!mFolder.wasSyncSuccessful()) {
    789             mErrorListener.onError(mFolder, false);
    790         }
    791 
    792         // Notify of changes to the Folder.
    793         onFolderStatusUpdated();
    794 
    795         // Blow away conversation items cache.
    796         ConversationItemViewModel.onFolderUpdated(mFolder);
    797     }
    798 
    799     /**
    800      * Updates the footer visibility and updates the conversation cursor
    801      */
    802     public void onConversationListStatusUpdated() {
    803         final ConversationCursor cursor = getConversationListCursor();
    804         final boolean showFooter = mFooterView.updateStatus(cursor);
    805         // Update the folder status, in case the cursor could affect it.
    806         onFolderStatusUpdated();
    807         mListAdapter.setFooterVisibility(showFooter);
    808 
    809         // Also change the cursor here.
    810         onCursorUpdated();
    811     }
    812 
    813     private void onFolderStatusUpdated() {
    814         // Update the sync status bar with sync results if needed
    815         checkSyncStatus();
    816 
    817         final ConversationCursor cursor = getConversationListCursor();
    818         Bundle extras = cursor != null ? cursor.getExtras() : Bundle.EMPTY;
    819         int errorStatus = extras.containsKey(UIProvider.CursorExtraKeys.EXTRA_ERROR) ?
    820                 extras.getInt(UIProvider.CursorExtraKeys.EXTRA_ERROR)
    821                 : UIProvider.LastSyncResult.SUCCESS;
    822         int cursorStatus = extras.getInt(UIProvider.CursorExtraKeys.EXTRA_STATUS);
    823         // We want to update the UI with this information if either we are loaded or complete, or
    824         // we have a folder with a non-0 count.
    825         final int folderCount = mFolder != null ? mFolder.totalCount : 0;
    826         if (errorStatus == UIProvider.LastSyncResult.SUCCESS
    827                 && (cursorStatus == UIProvider.CursorStatus.LOADED
    828                 || cursorStatus == UIProvider.CursorStatus.COMPLETE) || folderCount > 0) {
    829             updateSearchResultHeader(folderCount);
    830             if (folderCount == 0) {
    831                 mListView.setEmptyView(mEmptyView);
    832             }
    833         }
    834     }
    835 
    836     private void setSwipeAction() {
    837         int swipeSetting = Settings.getSwipeSetting(mAccount.settings);
    838         if (swipeSetting == Swipe.DISABLED
    839                 || !mAccount.supportsCapability(AccountCapabilities.UNDO)
    840                 || (mFolder != null && mFolder.isTrash())) {
    841             mListView.enableSwipe(false);
    842         } else {
    843             final int action;
    844             mListView.enableSwipe(true);
    845             if (ConversationListContext.isSearchResult(mViewContext)
    846                     || (mFolder != null && mFolder.isType(FolderType.SPAM))) {
    847                 action = R.id.delete;
    848             } else if (mFolder == null) {
    849                 action = R.id.remove_folder;
    850             } else {
    851                 // We have enough information to respect user settings.
    852                 switch (swipeSetting) {
    853                     case Swipe.ARCHIVE:
    854                         if (mAccount.supportsCapability(AccountCapabilities.ARCHIVE)) {
    855                             if (mFolder.supportsCapability(FolderCapabilities.ARCHIVE)) {
    856                                 action = R.id.archive;
    857                                 break;
    858                             } else if (mFolder.supportsCapability
    859                                     (FolderCapabilities.CAN_ACCEPT_MOVED_MESSAGES)) {
    860                                 action = R.id.remove_folder;
    861                                 break;
    862                             }
    863                         }
    864 
    865                         /*
    866                          * If we get here, we don't support archive, on either the account or the
    867                          * folder, so we want to fall through into the delete case.
    868                          */
    869                         //$FALL-THROUGH$
    870                     case Swipe.DELETE:
    871                     default:
    872                         action = R.id.delete;
    873                         break;
    874                 }
    875             }
    876             mListView.setSwipeAction(action);
    877         }
    878         mListView.setCurrentAccount(mAccount);
    879         mListView.setCurrentFolder(mFolder);
    880     }
    881 
    882     /**
    883      * Changes the conversation cursor in the list and sets selected position if none is set.
    884      */
    885     private void onCursorUpdated() {
    886         if (mCallbacks == null || mListAdapter == null) {
    887             return;
    888         }
    889         // Check against the previous cursor here and see if they are the same. If they are, then
    890         // do a notifyDataSetChanged.
    891         final ConversationCursor newCursor = mCallbacks.getConversationListCursor();
    892 
    893         if (newCursor == null && mListAdapter.getCursor() != null) {
    894             // We're losing our cursor, so save our scroll position
    895             saveLastScrolledPosition();
    896         }
    897 
    898         mListAdapter.swapCursor(newCursor);
    899         // When the conversation cursor is *updated*, we get back the same instance. In that
    900         // situation, CursorAdapter.swapCursor() silently returns, without forcing a
    901         // notifyDataSetChanged(). So let's force a call to notifyDataSetChanged, since an updated
    902         // cursor means that the dataset has changed.
    903         final int newCursorHash = (newCursor == null) ? 0 : newCursor.hashCode();
    904         if (mConversationCursorHash == newCursorHash && mConversationCursorHash != 0) {
    905             mListAdapter.notifyDataSetChanged();
    906         }
    907         mConversationCursorHash = newCursorHash;
    908 
    909         if (newCursor != null && newCursor.getCount() > 0) {
    910             newCursor.markContentsSeen();
    911             restoreLastScrolledPosition();
    912         }
    913 
    914         // If a current conversation is available, and none is selected in the list, then ask
    915         // the list to select the current conversation.
    916         final Conversation conv = mCallbacks.getCurrentConversation();
    917         if (conv != null) {
    918             if (mListView.getChoiceMode() != ListView.CHOICE_MODE_NONE
    919                     && mListView.getCheckedItemPosition() == -1) {
    920                 setSelected(conv.position, true);
    921             }
    922         }
    923     }
    924 
    925     public void commitDestructiveActions(boolean animate) {
    926         if (mListView != null) {
    927             mListView.commitDestructiveActions(animate);
    928 
    929         }
    930     }
    931 
    932     @Override
    933     public void onListItemSwiped(Collection<Conversation> conversations) {
    934         mUpdater.showNextConversation(conversations);
    935     }
    936 
    937     private void checkSyncStatus() {
    938         if (mFolder != null && mFolder.isSyncInProgress()) {
    939             LogUtils.d(LOG_TAG, "CLF.checkSyncStatus still syncing");
    940             // Still syncing, ignore
    941         } else {
    942             // Finished syncing:
    943             LogUtils.d(LOG_TAG, "CLF.checkSyncStatus done syncing");
    944             mConversationListView.onSyncFinished();
    945         }
    946     }
    947 
    948     /**
    949      * Displays the indefinite progress bar indicating a sync is in progress.  This
    950      * should only be called if user manually requested a sync, and not for background syncs.
    951      */
    952     protected void showSyncStatusBar() {
    953         mConversationListView.showSyncStatusBar();
    954     }
    955 
    956     /**
    957      * Clears all items in the list.
    958      */
    959     public void clear() {
    960         mListView.setAdapter(null);
    961     }
    962 
    963     private final ConversationSetObserver mConversationSetObserver = new ConversationSetObserver() {
    964         @Override
    965         public void onSetPopulated(final ConversationSelectionSet set) {
    966             // Do nothing
    967         }
    968 
    969         @Override
    970         public void onSetEmpty() {
    971             mSelectionModeExitedTimestamp = System.currentTimeMillis();
    972         }
    973 
    974         @Override
    975         public void onSetChanged(final ConversationSelectionSet set) {
    976             // Do nothing
    977         }
    978     };
    979 
    980     private void saveLastScrolledPosition() {
    981         if (mListAdapter.getCursor() == null) {
    982             // If you save your scroll position in an empty list, you're gonna have a bad time
    983             return;
    984         }
    985 
    986         final Parcelable savedState = mListView.onSaveInstanceState();
    987 
    988         mActivity.getListHandler().setConversationListScrollPosition(
    989                 mFolder.conversationListUri.toString(), savedState);
    990     }
    991 
    992     private void restoreLastScrolledPosition() {
    993         // Scroll to our previous position, if necessary
    994         if (!mScrollPositionRestored && mFolder != null) {
    995             final String key = mFolder.conversationListUri.toString();
    996             final Parcelable savedState = mActivity.getListHandler()
    997                     .getConversationListScrollPosition(key);
    998             if (savedState != null) {
    999                 mListView.onRestoreInstanceState(savedState);
   1000             }
   1001             mScrollPositionRestored = true;
   1002         }
   1003     }
   1004 }
   1005