TwoPaneController.java revision 405a344937675f57fc9c6988b2b124410a270f13
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
18package com.android.mail.ui;
19
20import android.app.Activity;
21import android.app.Fragment;
22import android.app.FragmentManager;
23import android.app.FragmentTransaction;
24import android.content.Intent;
25import android.os.Bundle;
26import android.support.annotation.IdRes;
27import android.support.annotation.LayoutRes;
28import android.support.v7.app.ActionBar;
29import android.view.KeyEvent;
30import android.view.View;
31import android.widget.ListView;
32
33import com.android.mail.ConversationListContext;
34import com.android.mail.R;
35import com.android.mail.providers.Account;
36import com.android.mail.providers.Conversation;
37import com.android.mail.providers.Folder;
38import com.android.mail.providers.UIProvider.ConversationListIcon;
39import com.android.mail.utils.LogUtils;
40import com.android.mail.utils.Utils;
41
42/**
43 * Controller for two-pane Mail activity. Two Pane is used for tablets, where screen real estate
44 * abounds.
45 */
46public final class TwoPaneController extends AbstractActivityController implements
47        ConversationViewFrame.DownEventListener {
48
49    private static final String SAVED_MISCELLANEOUS_VIEW = "saved-miscellaneous-view";
50    private static final String SAVED_MISCELLANEOUS_VIEW_TRANSACTION_ID =
51            "saved-miscellaneous-view-transaction-id";
52
53    private TwoPaneLayout mLayout;
54    @Deprecated
55    private Conversation mConversationToShow;
56
57    /**
58     * 2-pane, in wider configurations, allows peeking at a conversation view without having the
59     * conversation marked-as-read as far as read/unread state goes.<br>
60     * <br>
61     * This flag applies to {@link AbstractActivityController#mCurrentConversation} and indicates
62     * that the current conversation, if set, is in a 'peeking' state. If there is no current
63     * conversation, peeking is implied (in certain view configurations) and this value is
64     * meaningless.
65     */
66    // TODO: save in instance state
67    private boolean mCurrentConversationJustPeeking;
68
69    /**
70     * Used to determine whether onViewModeChanged should skip a potential
71     * fragment transaction that would remove a miscellaneous view.
72     */
73    private boolean mSavedMiscellaneousView = false;
74
75    private boolean mIsTabletLandscape;
76
77    public TwoPaneController(MailActivity activity, ViewMode viewMode) {
78        super(activity, viewMode);
79    }
80
81    public boolean isCurrentConversationJustPeeking() {
82        return mCurrentConversationJustPeeking;
83    }
84
85    private boolean isConversationOnlyMode() {
86        return getCurrentConversation() != null && !isCurrentConversationJustPeeking()
87                && !mLayout.shouldShowPreviewPanel();
88    }
89
90    /**
91     * Display the conversation list fragment.
92     */
93    private void initializeConversationListFragment() {
94        if (Intent.ACTION_SEARCH.equals(mActivity.getIntent().getAction())) {
95            if (shouldEnterSearchConvMode()) {
96                mViewMode.enterSearchResultsConversationMode();
97            } else {
98                mViewMode.enterSearchResultsListMode();
99            }
100        }
101        renderConversationList();
102    }
103
104    /**
105     * Render the conversation list in the correct pane.
106     */
107    private void renderConversationList() {
108        if (mActivity == null) {
109            return;
110        }
111        FragmentTransaction fragmentTransaction = mActivity.getFragmentManager().beginTransaction();
112        // Use cross fading animation.
113        fragmentTransaction.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE);
114        final ConversationListFragment conversationListFragment =
115                ConversationListFragment.newInstance(mConvListContext);
116        fragmentTransaction.replace(R.id.conversation_list_pane, conversationListFragment,
117                TAG_CONVERSATION_LIST);
118        fragmentTransaction.commitAllowingStateLoss();
119        // Set default navigation here once the ConversationListFragment is created.
120        conversationListFragment.setNextFocusLeftId(
121                getClfNextFocusLeftId(getFolderListFragment().isMinimized()));
122    }
123
124    @Override
125    public boolean doesActionChangeConversationListVisibility(final int action) {
126        if (action == R.id.settings
127                || action == R.id.compose
128                || action == R.id.help_info_menu_item
129                || action == R.id.feedback_menu_item) {
130            return true;
131        }
132
133        return false;
134    }
135
136    @Override
137    protected boolean isConversationListVisible() {
138        return !mLayout.isConversationListCollapsed();
139    }
140
141    @Override
142    protected void showConversationList(ConversationListContext listContext) {
143        initializeConversationListFragment();
144    }
145
146    @Override
147    public @LayoutRes int getContentViewResource() {
148        return R.layout.two_pane_activity;
149    }
150
151    @Override
152    public boolean onCreate(Bundle savedState) {
153        mLayout = (TwoPaneLayout) mActivity.findViewById(R.id.two_pane_activity);
154        if (mLayout == null) {
155            // We need the layout for everything. Crash/Return early if it is null.
156            LogUtils.wtf(LOG_TAG, "mLayout is null!");
157            return false;
158        }
159        mLayout.setController(this, Intent.ACTION_SEARCH.equals(mActivity.getIntent().getAction()));
160        mActivity.getWindow().setBackgroundDrawable(null);
161        mIsTabletLandscape = !mActivity.getResources().getBoolean(R.bool.list_collapsible);
162
163        final FolderListFragment flf = getFolderListFragment();
164        flf.setMiniDrawerEnabled(true);
165        flf.setMinimized(true);
166
167        if (savedState != null) {
168            mSavedMiscellaneousView = savedState.getBoolean(SAVED_MISCELLANEOUS_VIEW, false);
169            mMiscellaneousViewTransactionId =
170                    savedState.getInt(SAVED_MISCELLANEOUS_VIEW_TRANSACTION_ID, -1);
171        }
172
173        // 2-pane layout is the main listener of view mode changes, and issues secondary
174        // notifications upon animation completion:
175        // (onConversationVisibilityChanged, onConversationListVisibilityChanged)
176        mViewMode.addListener(mLayout);
177        return super.onCreate(savedState);
178    }
179
180    @Override
181    public void onSaveInstanceState(Bundle outState) {
182        super.onSaveInstanceState(outState);
183
184        outState.putBoolean(SAVED_MISCELLANEOUS_VIEW, mMiscellaneousViewTransactionId >= 0);
185        outState.putInt(SAVED_MISCELLANEOUS_VIEW_TRANSACTION_ID, mMiscellaneousViewTransactionId);
186    }
187
188    @Override
189    public void onWindowFocusChanged(boolean hasFocus) {
190        if (hasFocus && !mLayout.isConversationListCollapsed()) {
191            // The conversation list is visible.
192            informCursorVisiblity(true);
193        }
194    }
195
196    @Override
197    public void switchToDefaultInboxOrChangeAccount(Account account) {
198        if (mViewMode.isSearchMode()) {
199            // We are in an activity on top of the main navigation activity.
200            // We need to return to it with a result code that indicates it should navigate to
201            // a different folder.
202            final Intent intent = new Intent();
203            intent.putExtra(AbstractActivityController.EXTRA_ACCOUNT, account);
204            mActivity.setResult(Activity.RESULT_OK, intent);
205            mActivity.finish();
206            return;
207        }
208        if (mViewMode.getMode() != ViewMode.CONVERSATION_LIST) {
209            mViewMode.enterConversationListMode();
210        }
211        super.switchToDefaultInboxOrChangeAccount(account);
212    }
213
214    @Override
215    public void onFolderSelected(Folder folder) {
216        // It's possible that we are not in conversation list mode
217        if (mViewMode.isSearchMode()) {
218            // We are in an activity on top of the main navigation activity.
219            // We need to return to it with a result code that indicates it should navigate to
220            // a different folder.
221            final Intent intent = new Intent();
222            intent.putExtra(AbstractActivityController.EXTRA_FOLDER, folder);
223            mActivity.setResult(Activity.RESULT_OK, intent);
224            mActivity.finish();
225            return;
226        } else if (mViewMode.getMode() != ViewMode.CONVERSATION_LIST) {
227            mViewMode.enterConversationListMode();
228        }
229
230        setHierarchyFolder(folder);
231        super.onFolderSelected(folder);
232    }
233
234    public boolean isDrawerOpen() {
235        final FolderListFragment flf = getFolderListFragment();
236        return flf != null && !flf.isMinimized();
237    }
238
239    @Override
240    protected void toggleDrawerState() {
241        final FolderListFragment flf = getFolderListFragment();
242        if (flf == null) {
243            LogUtils.w(LOG_TAG, "no drawer to toggle open/closed");
244            return;
245        }
246        flf.setMinimized(!flf.isMinimized());
247        mLayout.requestLayout();
248        resetActionBarIcon();
249
250        final ConversationListFragment clf = getConversationListFragment();
251        if (clf != null) {
252            clf.setNextFocusLeftId(getClfNextFocusLeftId(flf.isMinimized()));
253
254            final SwipeableListView list = clf.getListView();
255            if (list != null) {
256                if (flf.isMinimized()) {
257                    list.stopPreventingSwipes();
258                } else {
259                    list.preventSwipesEntirely();
260                }
261            }
262        }
263    }
264
265    @Override
266    public boolean shouldPreventListSwipesEntirely() {
267        return isDrawerOpen();
268    }
269
270    @Override
271    public void onViewModeChanged(int newMode) {
272        if (!mSavedMiscellaneousView && mMiscellaneousViewTransactionId >= 0) {
273            final FragmentManager fragmentManager = mActivity.getFragmentManager();
274            fragmentManager.popBackStackImmediate(mMiscellaneousViewTransactionId,
275                    FragmentManager.POP_BACK_STACK_INCLUSIVE);
276            mMiscellaneousViewTransactionId = -1;
277        }
278        mSavedMiscellaneousView = false;
279
280        super.onViewModeChanged(newMode);
281        if (!isConversationOnlyMode()) {
282            mFloatingComposeButton.setVisibility(View.VISIBLE);
283        }
284        if (newMode != ViewMode.WAITING_FOR_ACCOUNT_INITIALIZATION) {
285            // Clear the wait fragment
286            hideWaitForInitialization();
287        }
288        // In conversation mode, if the conversation list is not visible, then the user cannot
289        // see the selected conversations. Disable the CAB mode while leaving the selected set
290        // untouched.
291        // When the conversation list is made visible again, try to enable the CAB
292        // mode if any conversations are selected.
293        if (newMode == ViewMode.CONVERSATION || newMode == ViewMode.CONVERSATION_LIST
294                || ViewMode.isAdMode(newMode)) {
295            enableOrDisableCab();
296        }
297    }
298
299    private @IdRes int getClfNextFocusLeftId(boolean drawerMinimized) {
300        return (drawerMinimized) ? R.id.current_account_avatar : android.R.id.list;
301    }
302
303    @Override
304    public void onConversationVisibilityChanged(boolean visible) {
305        super.onConversationVisibilityChanged(visible);
306        if (!visible) {
307            mPagerController.hide(false /* changeVisibility */);
308        } else if (mConversationToShow != null) {
309            mPagerController.show(mAccount, mFolder, mConversationToShow,
310                    false /* changeVisibility */);
311            mConversationToShow = null;
312        }
313    }
314
315    @Override
316    public void onConversationListVisibilityChanged(boolean visible) {
317        super.onConversationListVisibilityChanged(visible);
318        enableOrDisableCab();
319    }
320
321    @Override
322    public void resetActionBarIcon() {
323        final ActionBar ab = mActivity.getSupportActionBar();
324        final boolean isChildFolder = getFolder() != null && !Utils.isEmpty(getFolder().parent);
325        if (isConversationOnlyMode() || isChildFolder) {
326            ab.setHomeAsUpIndicator(R.drawable.ic_arrow_back_wht_24dp);
327            ab.setHomeActionContentDescription(0 /* system default */);
328        } else {
329            ab.setHomeAsUpIndicator(R.drawable.ic_drawer);
330            ab.setHomeActionContentDescription(
331                    isDrawerOpen() ? R.string.drawer_close : R.string.drawer_open);
332        }
333    }
334
335    /**
336     * Enable or disable the CAB mode based on the visibility of the conversation list fragment.
337     */
338    private void enableOrDisableCab() {
339        if (mLayout.isConversationListCollapsed()) {
340            disableCabMode();
341        } else {
342            enableCabMode();
343        }
344    }
345
346    @Override
347    public void onSetPopulated(ConversationSelectionSet set) {
348        super.onSetPopulated(set);
349
350        boolean showSenderImage =
351                (mAccount.settings.convListIcon == ConversationListIcon.SENDER_IMAGE);
352        if (!showSenderImage && mViewMode.isListMode()) {
353            getConversationListFragment().setChoiceNone();
354        }
355    }
356
357    @Override
358    public void onSetEmpty() {
359        super.onSetEmpty();
360
361        boolean showSenderImage =
362                (mAccount.settings.convListIcon == ConversationListIcon.SENDER_IMAGE);
363        if (!showSenderImage && mViewMode.isListMode()) {
364            getConversationListFragment().revertChoiceMode();
365        }
366    }
367
368    @Override
369    protected void showConversation(Conversation conversation, boolean markAsRead) {
370        super.showConversation(conversation, markAsRead);
371
372        // 2-pane can ignore inLoaderCallbacks because it doesn't use
373        // FragmentManager.popBackStack().
374
375        if (mActivity == null) {
376            return;
377        }
378        if (conversation == null) {
379            handleBackPress();
380            return;
381        }
382        // If conversation list is not visible, then the user cannot see the CAB mode, so exit it.
383        // This is needed here (in addition to during viewmode changes) because orientation changes
384        // while viewing a conversation don't change the viewmode: the mode stays
385        // ViewMode.CONVERSATION and yet the conversation list goes in and out of visibility.
386        enableOrDisableCab();
387
388        // close the drawer, if open
389        if (isDrawerOpen()) {
390            toggleDrawerState();
391        }
392
393        // When a mode change is required, wait for onConversationVisibilityChanged(), the signal
394        // that the mode change animation has finished, before rendering the conversation.
395        mConversationToShow = conversation;
396        mCurrentConversationJustPeeking = !markAsRead;
397
398        final int mode = mViewMode.getMode();
399        LogUtils.i(LOG_TAG, "IN TPC.showConv, oldMode=%s conv=%s", mode, mConversationToShow);
400        if (mode == ViewMode.SEARCH_RESULTS_LIST || mode == ViewMode.SEARCH_RESULTS_CONVERSATION) {
401            mViewMode.enterSearchResultsConversationMode();
402        } else {
403            mViewMode.enterConversationMode();
404        }
405        // load the conversation immediately if we're already in conversation mode
406        if (!mLayout.isModeChangePending()) {
407            onConversationVisibilityChanged(true);
408        } else {
409            LogUtils.i(LOG_TAG, "TPC.showConversation will wait for TPL.animationEnd to show!");
410        }
411    }
412
413    @Override
414    public void onConversationSelected(Conversation conversation, boolean inLoaderCallbacks) {
415        super.onConversationSelected(conversation, inLoaderCallbacks);
416        if (!mCurrentConversationJustPeeking) {
417            // Shift the focus to the conversation in landscape mode.
418            mPagerController.focusPager();
419        }
420    }
421
422    @Override
423    public void onConversationFocused(Conversation conversation) {
424        if (mIsTabletLandscape) {
425            showConversation(conversation, false /* markAsRead */);
426        }
427    }
428
429    @Override
430    public void setCurrentConversation(Conversation conversation) {
431        // Order is important! We want to calculate different *before* the superclass changes
432        // mCurrentConversation, so before super.setCurrentConversation().
433        final long oldId = mCurrentConversation != null ? mCurrentConversation.id : -1;
434        final long newId = conversation != null ? conversation.id : -1;
435        final boolean different = oldId != newId;
436
437        // This call might change mCurrentConversation.
438        super.setCurrentConversation(conversation);
439
440        final ConversationListFragment convList = getConversationListFragment();
441        if (convList != null && conversation != null) {
442            convList.setSelected(conversation.position, different);
443        }
444    }
445
446    @Override
447    protected void showWaitForInitialization() {
448        super.showWaitForInitialization();
449
450        FragmentTransaction fragmentTransaction = mActivity.getFragmentManager().beginTransaction();
451        fragmentTransaction.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN);
452        fragmentTransaction.replace(R.id.conversation_list_pane, getWaitFragment(), TAG_WAIT);
453        fragmentTransaction.commitAllowingStateLoss();
454    }
455
456    @Override
457    protected void hideWaitForInitialization() {
458        final WaitFragment waitFragment = getWaitFragment();
459        if (waitFragment == null) {
460            // We aren't showing a wait fragment: nothing to do
461            return;
462        }
463        // Remove the existing wait fragment from the back stack.
464        final FragmentTransaction fragmentTransaction =
465                mActivity.getFragmentManager().beginTransaction();
466        fragmentTransaction.remove(waitFragment);
467        fragmentTransaction.commitAllowingStateLoss();
468        super.hideWaitForInitialization();
469        if (mViewMode.isWaitingForSync()) {
470            // We should come out of wait mode and display the account inbox.
471            loadAccountInbox();
472        }
473    }
474
475    /**
476     * Up works as follows:
477     * 1) If the user is in a conversation and:
478     *  a) the conversation list is hidden (portrait mode), shows the conv list and
479     *  stays in conversation view mode.
480     *  b) the conversation list is shown, goes back to conversation list mode.
481     * 2) If the user is in search results, up exits search.
482     * mode and returns the user to whatever view they were in when they began search.
483     * 3) If the user is in conversation list mode, there is no up.
484     */
485    @Override
486    public boolean handleUpPress() {
487        if (isConversationOnlyMode()) {
488            handleBackPress();
489        } else {
490            toggleDrawerState();
491        }
492
493        return true;
494    }
495
496    @Override
497    public boolean handleBackPress() {
498        // Clear any visible undo bars.
499        mToastBar.hide(false, false /* actionClicked */);
500        if (isDrawerOpen()) {
501            toggleDrawerState();
502        } else {
503            popView(false);
504        }
505        return true;
506    }
507
508    /**
509     * Pops the "view stack" to the last screen the user was viewing.
510     *
511     * @param preventClose Whether to prevent closing the app if the stack is empty.
512     */
513    protected void popView(boolean preventClose) {
514        // If the user is in search query entry mode, or the user is viewing
515        // search results, exit
516        // the mode.
517        int mode = mViewMode.getMode();
518        if (mode == ViewMode.SEARCH_RESULTS_LIST) {
519            mActivity.finish();
520        } else if (mode == ViewMode.CONVERSATION || mViewMode.isAdMode()) {
521            // Go to conversation list.
522            mViewMode.enterConversationListMode();
523        } else if (mode == ViewMode.SEARCH_RESULTS_CONVERSATION) {
524            mViewMode.enterSearchResultsListMode();
525        } else {
526            // The Folder List fragment can be null for monkeys where we get a back before the
527            // folder list has had a chance to initialize.
528            final FolderListFragment folderList = getFolderListFragment();
529            if (mode == ViewMode.CONVERSATION_LIST && folderList != null
530                    && !Folder.isRoot(mFolder)) {
531                // If the user navigated via the left folders list into a child folder,
532                // back should take the user up to the parent folder's conversation list.
533                navigateUpFolderHierarchy();
534            // Otherwise, if we are in the conversation list but not in the default
535            // inbox and not on expansive layouts, we want to switch back to the default
536            // inbox. This fixes b/9006969 so that on smaller tablets where we have this
537            // hybrid one and two-pane mode, we will return to the inbox. On larger tablets,
538            // we will instead exit the app.
539            } else if (!preventClose) {
540                // There is nothing else to pop off the stack.
541                mActivity.finish();
542            }
543        }
544    }
545
546    @Override
547    public boolean shouldShowFirstConversation() {
548        return Intent.ACTION_SEARCH.equals(mActivity.getIntent().getAction())
549                && shouldEnterSearchConvMode();
550    }
551
552    @Override
553    public void onUndoAvailable(ToastBarOperation op) {
554        final int mode = mViewMode.getMode();
555        final ConversationListFragment convList = getConversationListFragment();
556
557        switch (mode) {
558            case ViewMode.SEARCH_RESULTS_LIST:
559            case ViewMode.CONVERSATION_LIST:
560            case ViewMode.SEARCH_RESULTS_CONVERSATION:
561            case ViewMode.CONVERSATION:
562                if (convList != null) {
563                    mToastBar.show(getUndoClickedListener(convList.getAnimatedAdapter()),
564                            Utils.convertHtmlToPlainText
565                                (op.getDescription(mActivity.getActivityContext())),
566                            R.string.undo,
567                            true,  /* replaceVisibleToast */
568                            op);
569                }
570        }
571    }
572
573    @Override
574    public void onError(final Folder folder, boolean replaceVisibleToast) {
575        showErrorToast(folder, replaceVisibleToast);
576    }
577
578    @Override
579    public boolean isDrawerEnabled() {
580        // two-pane has its own drawer-like thing that expands inline from a minimized state.
581        return false;
582    }
583
584    @Override
585    public int getFolderListViewChoiceMode() {
586        // By default, we want to allow one item to be selected in the folder list
587        return ListView.CHOICE_MODE_SINGLE;
588    }
589
590    private int mMiscellaneousViewTransactionId = -1;
591
592    @Override
593    public void launchFragment(final Fragment fragment, final int selectPosition) {
594        final int containerViewId = TwoPaneLayout.MISCELLANEOUS_VIEW_ID;
595
596        final FragmentManager fragmentManager = mActivity.getFragmentManager();
597        if (fragmentManager.findFragmentByTag(TAG_CUSTOM_FRAGMENT) == null) {
598            final FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction();
599            fragmentTransaction.addToBackStack(null);
600            fragmentTransaction.replace(containerViewId, fragment, TAG_CUSTOM_FRAGMENT);
601            mMiscellaneousViewTransactionId = fragmentTransaction.commitAllowingStateLoss();
602            fragmentManager.executePendingTransactions();
603        }
604
605        if (selectPosition >= 0) {
606            getConversationListFragment().setRawSelected(selectPosition, true);
607        }
608    }
609
610    @Override
611    public boolean onInterceptCVDownEvent() {
612        // handle a down event on CV by closing the drawer if open
613        if (isDrawerOpen()) {
614            toggleDrawerState();
615            return true;
616        }
617        return false;
618    }
619
620    @Override
621    public boolean onInterceptKeyFromCV(int keyCode, KeyEvent keyEvent, boolean navigateAway) {
622        // Override left/right key presses in landscape mode.
623        if (navigateAway) {
624            if (keyEvent.getAction() == KeyEvent.ACTION_UP) {
625                ConversationListFragment clf = getConversationListFragment();
626                if (clf != null) {
627                    clf.getListView().requestFocus();
628                }
629            }
630            return true;
631        }
632        return false;
633    }
634
635    @Override
636    public boolean isTwoPaneLandscape() {
637        return mIsTabletLandscape;
638    }
639
640    @Override
641    public boolean shouldShowSearchBarByDefault() {
642        final int mode = mViewMode.getMode();
643        return mode == ViewMode.SEARCH_RESULTS_LIST ||
644                (mIsTabletLandscape && mode == ViewMode.SEARCH_RESULTS_CONVERSATION);
645    }
646}
647