1/*
2 * Copyright (C) 2015 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16package com.android.messaging.ui.conversationlist;
17
18import android.app.Activity;
19import android.app.Fragment;
20import android.content.Context;
21import android.database.Cursor;
22import android.graphics.Rect;
23import android.net.Uri;
24import android.os.Bundle;
25import android.os.Parcelable;
26import android.support.v4.view.ViewCompat;
27import android.support.v4.view.ViewGroupCompat;
28import android.support.v7.widget.LinearLayoutManager;
29import android.support.v7.widget.RecyclerView;
30import android.view.LayoutInflater;
31import android.view.Menu;
32import android.view.MenuInflater;
33import android.view.MenuItem;
34import android.view.View;
35import android.view.View.OnClickListener;
36import android.view.ViewGroup;
37import android.view.ViewGroup.MarginLayoutParams;
38import android.view.ViewPropertyAnimator;
39import android.view.accessibility.AccessibilityManager;
40import android.widget.AbsListView;
41import android.widget.ImageView;
42
43import com.android.messaging.R;
44import com.android.messaging.annotation.VisibleForAnimation;
45import com.android.messaging.datamodel.DataModel;
46import com.android.messaging.datamodel.binding.Binding;
47import com.android.messaging.datamodel.binding.BindingBase;
48import com.android.messaging.datamodel.data.ConversationListData;
49import com.android.messaging.datamodel.data.ConversationListData.ConversationListDataListener;
50import com.android.messaging.datamodel.data.ConversationListItemData;
51import com.android.messaging.ui.BugleAnimationTags;
52import com.android.messaging.ui.ListEmptyView;
53import com.android.messaging.ui.SnackBarInteraction;
54import com.android.messaging.ui.UIIntents;
55import com.android.messaging.util.AccessibilityUtil;
56import com.android.messaging.util.Assert;
57import com.android.messaging.util.ImeUtil;
58import com.android.messaging.util.LogUtil;
59import com.android.messaging.util.UiUtils;
60import com.google.common.annotations.VisibleForTesting;
61
62import java.util.ArrayList;
63import java.util.List;
64
65/**
66 * Shows a list of conversations.
67 */
68public class ConversationListFragment extends Fragment implements ConversationListDataListener,
69        ConversationListItemView.HostInterface {
70    private static final String BUNDLE_ARCHIVED_MODE = "archived_mode";
71    private static final String BUNDLE_FORWARD_MESSAGE_MODE = "forward_message_mode";
72    private static final boolean VERBOSE = false;
73
74    private MenuItem mShowBlockedMenuItem;
75    private boolean mArchiveMode;
76    private boolean mBlockedAvailable;
77    private boolean mForwardMessageMode;
78
79    public interface ConversationListFragmentHost {
80        public void onConversationClick(final ConversationListData listData,
81                                        final ConversationListItemData conversationListItemData,
82                                        final boolean isLongClick,
83                                        final ConversationListItemView conversationView);
84        public void onCreateConversationClick();
85        public boolean isConversationSelected(final String conversationId);
86        public boolean isSwipeAnimatable();
87        public boolean isSelectionMode();
88        public boolean hasWindowFocus();
89    }
90
91    private ConversationListFragmentHost mHost;
92    private RecyclerView mRecyclerView;
93    private ImageView mStartNewConversationButton;
94    private ListEmptyView mEmptyListMessageView;
95    private ConversationListAdapter mAdapter;
96
97    // Saved Instance State Data - only for temporal data which is nice to maintain but not
98    // critical for correctness.
99    private static final String SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY =
100            "conversationListViewState";
101    private Parcelable mListState;
102
103    @VisibleForTesting
104    final Binding<ConversationListData> mListBinding = BindingBase.createBinding(this);
105
106    public static ConversationListFragment createArchivedConversationListFragment() {
107        return createConversationListFragment(BUNDLE_ARCHIVED_MODE);
108    }
109
110    public static ConversationListFragment createForwardMessageConversationListFragment() {
111        return createConversationListFragment(BUNDLE_FORWARD_MESSAGE_MODE);
112    }
113
114    public static ConversationListFragment createConversationListFragment(String modeKeyName) {
115        final ConversationListFragment fragment = new ConversationListFragment();
116        final Bundle bundle = new Bundle();
117        bundle.putBoolean(modeKeyName, true);
118        fragment.setArguments(bundle);
119        return fragment;
120    }
121
122    /**
123     * {@inheritDoc} from Fragment
124     */
125    @Override
126    public void onCreate(final Bundle bundle) {
127        super.onCreate(bundle);
128        mListBinding.getData().init(getLoaderManager(), mListBinding);
129        mAdapter = new ConversationListAdapter(getActivity(), null, this);
130    }
131
132    @Override
133    public void onResume() {
134        super.onResume();
135
136        Assert.notNull(mHost);
137        setScrolledToNewestConversationIfNeeded();
138
139        updateUi();
140    }
141
142    public void setScrolledToNewestConversationIfNeeded() {
143        if (!mArchiveMode
144                && !mForwardMessageMode
145                && isScrolledToFirstConversation()
146                && mHost.hasWindowFocus()) {
147            mListBinding.getData().setScrolledToNewestConversation(true);
148        }
149    }
150
151    private boolean isScrolledToFirstConversation() {
152        int firstItemPosition = ((LinearLayoutManager) mRecyclerView.getLayoutManager())
153                .findFirstCompletelyVisibleItemPosition();
154        return firstItemPosition == 0;
155    }
156
157    /**
158     * {@inheritDoc} from Fragment
159     */
160    @Override
161    public void onDestroy() {
162        super.onDestroy();
163        mListBinding.unbind();
164        mHost = null;
165    }
166
167    /**
168     * {@inheritDoc} from Fragment
169     */
170    @Override
171    public View onCreateView(final LayoutInflater inflater, final ViewGroup container,
172            final Bundle savedInstanceState) {
173        final ViewGroup rootView = (ViewGroup) inflater.inflate(R.layout.conversation_list_fragment,
174                container, false);
175        mRecyclerView = (RecyclerView) rootView.findViewById(android.R.id.list);
176        mEmptyListMessageView = (ListEmptyView) rootView.findViewById(R.id.no_conversations_view);
177        mEmptyListMessageView.setImageHint(R.drawable.ic_oobe_conv_list);
178        // The default behavior for default layout param generation by LinearLayoutManager is to
179        // provide width and height of WRAP_CONTENT, but this is not desirable for
180        // ConversationListFragment; the view in each row should be a width of MATCH_PARENT so that
181        // the entire row is tappable.
182        final Activity activity = getActivity();
183        final LinearLayoutManager manager = new LinearLayoutManager(activity) {
184            @Override
185            public RecyclerView.LayoutParams generateDefaultLayoutParams() {
186                return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
187                        ViewGroup.LayoutParams.WRAP_CONTENT);
188            }
189        };
190        mRecyclerView.setLayoutManager(manager);
191        mRecyclerView.setHasFixedSize(true);
192        mRecyclerView.setAdapter(mAdapter);
193        mRecyclerView.setOnScrollListener(new RecyclerView.OnScrollListener() {
194            int mCurrentState = AbsListView.OnScrollListener.SCROLL_STATE_IDLE;
195
196            @Override
197            public void onScrolled(final RecyclerView recyclerView, final int dx, final int dy) {
198                if (mCurrentState == AbsListView.OnScrollListener.SCROLL_STATE_TOUCH_SCROLL
199                        || mCurrentState == AbsListView.OnScrollListener.SCROLL_STATE_FLING) {
200                    ImeUtil.get().hideImeKeyboard(getActivity(), mRecyclerView);
201                }
202
203                if (isScrolledToFirstConversation()) {
204                    setScrolledToNewestConversationIfNeeded();
205                } else {
206                    mListBinding.getData().setScrolledToNewestConversation(false);
207                }
208            }
209
210            @Override
211            public void onScrollStateChanged(final RecyclerView recyclerView, final int newState) {
212                mCurrentState = newState;
213            }
214        });
215        mRecyclerView.addOnItemTouchListener(new ConversationListSwipeHelper(mRecyclerView));
216
217        if (savedInstanceState != null) {
218            mListState = savedInstanceState.getParcelable(SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY);
219        }
220
221        mStartNewConversationButton = (ImageView) rootView.findViewById(
222                R.id.start_new_conversation_button);
223        if (mArchiveMode) {
224            mStartNewConversationButton.setVisibility(View.GONE);
225        } else {
226            mStartNewConversationButton.setVisibility(View.VISIBLE);
227            mStartNewConversationButton.setOnClickListener(new OnClickListener() {
228                @Override
229                public void onClick(final View clickView) {
230                    mHost.onCreateConversationClick();
231                }
232            });
233        }
234        ViewCompat.setTransitionName(mStartNewConversationButton, BugleAnimationTags.TAG_FABICON);
235
236        // The root view has a non-null background, which by default is deemed by the framework
237        // to be a "transition group," where all child views are animated together during an
238        // activity transition. However, we want each individual items in the recycler view to
239        // show explode animation themselves, so we explicitly tag the root view to be a non-group.
240        ViewGroupCompat.setTransitionGroup(rootView, false);
241
242        setHasOptionsMenu(true);
243        return rootView;
244    }
245
246    @Override
247    public void onAttach(final Activity activity) {
248        super.onAttach(activity);
249        if (VERBOSE) {
250            LogUtil.v(LogUtil.BUGLE_TAG, "Attaching List");
251        }
252        final Bundle arguments = getArguments();
253        if (arguments != null) {
254            mArchiveMode = arguments.getBoolean(BUNDLE_ARCHIVED_MODE, false);
255            mForwardMessageMode = arguments.getBoolean(BUNDLE_FORWARD_MESSAGE_MODE, false);
256        }
257        mListBinding.bind(DataModel.get().createConversationListData(activity, this, mArchiveMode));
258    }
259
260
261    @Override
262    public void onSaveInstanceState(final Bundle outState) {
263        super.onSaveInstanceState(outState);
264        if (mListState != null) {
265            outState.putParcelable(SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY, mListState);
266        }
267    }
268
269    @Override
270    public void onPause() {
271        super.onPause();
272        mListState = mRecyclerView.getLayoutManager().onSaveInstanceState();
273        mListBinding.getData().setScrolledToNewestConversation(false);
274    }
275
276    /**
277     * Call this immediately after attaching the fragment
278     */
279    public void setHost(final ConversationListFragmentHost host) {
280        Assert.isNull(mHost);
281        mHost = host;
282    }
283
284    @Override
285    public void onConversationListCursorUpdated(final ConversationListData data,
286            final Cursor cursor) {
287        mListBinding.ensureBound(data);
288        final Cursor oldCursor = mAdapter.swapCursor(cursor);
289        updateEmptyListUi(cursor == null || cursor.getCount() == 0);
290        if (mListState != null && cursor != null && oldCursor == null) {
291            mRecyclerView.getLayoutManager().onRestoreInstanceState(mListState);
292        }
293    }
294
295    @Override
296    public void setBlockedParticipantsAvailable(final boolean blockedAvailable) {
297        mBlockedAvailable = blockedAvailable;
298        if (mShowBlockedMenuItem != null) {
299            mShowBlockedMenuItem.setVisible(blockedAvailable);
300        }
301    }
302
303    public void updateUi() {
304        mAdapter.notifyDataSetChanged();
305    }
306
307    @Override
308    public void onPrepareOptionsMenu(final Menu menu) {
309        super.onPrepareOptionsMenu(menu);
310        final MenuItem startNewConversationMenuItem =
311                menu.findItem(R.id.action_start_new_conversation);
312        if (startNewConversationMenuItem != null) {
313            // It is recommended for the Floating Action button functionality to be duplicated as a
314            // menu
315            AccessibilityManager accessibilityManager = (AccessibilityManager)
316                    getActivity().getSystemService(Context.ACCESSIBILITY_SERVICE);
317            startNewConversationMenuItem.setVisible(accessibilityManager
318                    .isTouchExplorationEnabled());
319        }
320
321        final MenuItem archive = menu.findItem(R.id.action_show_archived);
322        if (archive != null) {
323            archive.setVisible(true);
324        }
325    }
326
327    @Override
328    public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) {
329        if (!isAdded()) {
330            // Guard against being called before we're added to the activity
331            return;
332        }
333
334        mShowBlockedMenuItem = menu.findItem(R.id.action_show_blocked_contacts);
335        if (mShowBlockedMenuItem != null) {
336            mShowBlockedMenuItem.setVisible(mBlockedAvailable);
337        }
338    }
339
340    /**
341     * {@inheritDoc} from ConversationListItemView.HostInterface
342     */
343    @Override
344    public void onConversationClicked(final ConversationListItemData conversationListItemData,
345            final boolean isLongClick, final ConversationListItemView conversationView) {
346        final ConversationListData listData = mListBinding.getData();
347        mHost.onConversationClick(listData, conversationListItemData, isLongClick,
348                conversationView);
349    }
350
351    /**
352     * {@inheritDoc} from ConversationListItemView.HostInterface
353     */
354    @Override
355    public boolean isConversationSelected(final String conversationId) {
356        return mHost.isConversationSelected(conversationId);
357    }
358
359    @Override
360    public boolean isSwipeAnimatable() {
361        return mHost.isSwipeAnimatable();
362    }
363
364    // Show and hide empty list UI as needed with appropriate text based on view specifics
365    private void updateEmptyListUi(final boolean isEmpty) {
366        if (isEmpty) {
367            int emptyListText;
368            if (!mListBinding.getData().getHasFirstSyncCompleted()) {
369                emptyListText = R.string.conversation_list_first_sync_text;
370            } else if (mArchiveMode) {
371                emptyListText = R.string.archived_conversation_list_empty_text;
372            } else {
373                emptyListText = R.string.conversation_list_empty_text;
374            }
375            mEmptyListMessageView.setTextHint(emptyListText);
376            mEmptyListMessageView.setVisibility(View.VISIBLE);
377            mEmptyListMessageView.setIsImageVisible(true);
378            mEmptyListMessageView.setIsVerticallyCentered(true);
379        } else {
380            mEmptyListMessageView.setVisibility(View.GONE);
381        }
382    }
383
384    @Override
385    public List<SnackBarInteraction> getSnackBarInteractions() {
386        final List<SnackBarInteraction> interactions = new ArrayList<SnackBarInteraction>(1);
387        final SnackBarInteraction fabInteraction =
388                new SnackBarInteraction.BasicSnackBarInteraction(mStartNewConversationButton);
389        interactions.add(fabInteraction);
390        return interactions;
391    }
392
393    private ViewPropertyAnimator getNormalizedFabAnimator() {
394        return mStartNewConversationButton.animate()
395                .setInterpolator(UiUtils.DEFAULT_INTERPOLATOR)
396                .setDuration(getActivity().getResources().getInteger(
397                        R.integer.fab_animation_duration_ms));
398    }
399
400    public ViewPropertyAnimator dismissFab() {
401        // To prevent clicking while animating.
402        mStartNewConversationButton.setEnabled(false);
403        final MarginLayoutParams lp =
404                (MarginLayoutParams) mStartNewConversationButton.getLayoutParams();
405        final float fabWidthWithLeftRightMargin = mStartNewConversationButton.getWidth()
406                + lp.leftMargin + lp.rightMargin;
407        final int direction = AccessibilityUtil.isLayoutRtl(mStartNewConversationButton) ? -1 : 1;
408        return getNormalizedFabAnimator().translationX(direction * fabWidthWithLeftRightMargin);
409    }
410
411    public ViewPropertyAnimator showFab() {
412        return getNormalizedFabAnimator().translationX(0).withEndAction(new Runnable() {
413            @Override
414            public void run() {
415                // Re-enable clicks after the animation.
416                mStartNewConversationButton.setEnabled(true);
417            }
418        });
419    }
420
421    public View getHeroElementForTransition() {
422        return mArchiveMode ? null : mStartNewConversationButton;
423    }
424
425    @VisibleForAnimation
426    public RecyclerView getRecyclerView() {
427        return mRecyclerView;
428    }
429
430    @Override
431    public void startFullScreenPhotoViewer(
432            final Uri initialPhoto, final Rect initialPhotoBounds, final Uri photosUri) {
433        UIIntents.get().launchFullScreenPhotoViewer(
434                getActivity(), initialPhoto, initialPhotoBounds, photosUri);
435    }
436
437    @Override
438    public void startFullScreenVideoViewer(final Uri videoUri) {
439        UIIntents.get().launchFullScreenVideoViewer(getActivity(), videoUri);
440    }
441
442    @Override
443    public boolean isSelectionMode() {
444        return mHost != null && mHost.isSelectionMode();
445    }
446}
447