• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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  */
16 package com.android.messaging.ui.conversationlist;
17 
18 import android.app.Activity;
19 import android.app.Fragment;
20 import android.content.Context;
21 import android.database.Cursor;
22 import android.graphics.Rect;
23 import android.net.Uri;
24 import android.os.Bundle;
25 import android.os.Parcelable;
26 import androidx.core.view.ViewCompat;
27 import androidx.core.view.ViewGroupCompat;
28 import androidx.recyclerview.widget.LinearLayoutManager;
29 import androidx.recyclerview.widget.RecyclerView;
30 import android.view.LayoutInflater;
31 import android.view.Menu;
32 import android.view.MenuInflater;
33 import android.view.MenuItem;
34 import android.view.View;
35 import android.view.View.OnClickListener;
36 import android.view.ViewGroup;
37 import android.view.ViewGroup.MarginLayoutParams;
38 import android.view.ViewPropertyAnimator;
39 import android.view.accessibility.AccessibilityManager;
40 import android.widget.AbsListView;
41 import android.widget.ImageView;
42 
43 import com.android.messaging.R;
44 import com.android.messaging.annotation.VisibleForAnimation;
45 import com.android.messaging.datamodel.DataModel;
46 import com.android.messaging.datamodel.binding.Binding;
47 import com.android.messaging.datamodel.binding.BindingBase;
48 import com.android.messaging.datamodel.data.ConversationListData;
49 import com.android.messaging.datamodel.data.ConversationListData.ConversationListDataListener;
50 import com.android.messaging.datamodel.data.ConversationListItemData;
51 import com.android.messaging.ui.BugleAnimationTags;
52 import com.android.messaging.ui.ListEmptyView;
53 import com.android.messaging.ui.SnackBarInteraction;
54 import com.android.messaging.ui.UIIntents;
55 import com.android.messaging.util.AccessibilityUtil;
56 import com.android.messaging.util.Assert;
57 import com.android.messaging.util.ImeUtil;
58 import com.android.messaging.util.LogUtil;
59 import com.android.messaging.util.UiUtils;
60 import com.google.common.annotations.VisibleForTesting;
61 
62 import java.util.ArrayList;
63 import java.util.List;
64 
65 /**
66  * Shows a list of conversations.
67  */
68 public 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 {
onConversationClick(final ConversationListData listData, final ConversationListItemData conversationListItemData, final boolean isLongClick, final ConversationListItemView conversationView)80         public void onConversationClick(final ConversationListData listData,
81                                         final ConversationListItemData conversationListItemData,
82                                         final boolean isLongClick,
83                                         final ConversationListItemView conversationView);
onCreateConversationClick()84         public void onCreateConversationClick();
isConversationSelected(final String conversationId)85         public boolean isConversationSelected(final String conversationId);
isSwipeAnimatable()86         public boolean isSwipeAnimatable();
isSelectionMode()87         public boolean isSelectionMode();
hasWindowFocus()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 
createArchivedConversationListFragment()106     public static ConversationListFragment createArchivedConversationListFragment() {
107         return createConversationListFragment(BUNDLE_ARCHIVED_MODE);
108     }
109 
createForwardMessageConversationListFragment()110     public static ConversationListFragment createForwardMessageConversationListFragment() {
111         return createConversationListFragment(BUNDLE_FORWARD_MESSAGE_MODE);
112     }
113 
createConversationListFragment(String modeKeyName)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
onCreate(final Bundle bundle)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
onResume()133     public void onResume() {
134         super.onResume();
135 
136         Assert.notNull(mHost);
137         setScrolledToNewestConversationIfNeeded();
138 
139         updateUi();
140     }
141 
setScrolledToNewestConversationIfNeeded()142     public void setScrolledToNewestConversationIfNeeded() {
143         if (!mArchiveMode
144                 && !mForwardMessageMode
145                 && isScrolledToFirstConversation()
146                 && mHost.hasWindowFocus()) {
147             mListBinding.getData().setScrolledToNewestConversation(true);
148         }
149     }
150 
isScrolledToFirstConversation()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
onDestroy()161     public void onDestroy() {
162         super.onDestroy();
163         mListBinding.unbind();
164         mHost = null;
165     }
166 
167     /**
168      * {@inheritDoc} from Fragment
169      */
170     @Override
onCreateView(final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState)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
onAttach(final Activity activity)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
onSaveInstanceState(final Bundle outState)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
onPause()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      */
setHost(final ConversationListFragmentHost host)279     public void setHost(final ConversationListFragmentHost host) {
280         Assert.isNull(mHost);
281         mHost = host;
282     }
283 
284     @Override
onConversationListCursorUpdated(final ConversationListData data, final Cursor cursor)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
setBlockedParticipantsAvailable(final boolean blockedAvailable)296     public void setBlockedParticipantsAvailable(final boolean blockedAvailable) {
297         mBlockedAvailable = blockedAvailable;
298         if (mShowBlockedMenuItem != null) {
299             mShowBlockedMenuItem.setVisible(blockedAvailable);
300         }
301     }
302 
updateUi()303     public void updateUi() {
304         mAdapter.notifyDataSetChanged();
305     }
306 
307     @Override
onPrepareOptionsMenu(final Menu menu)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
onCreateOptionsMenu(final Menu menu, final MenuInflater inflater)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
onConversationClicked(final ConversationListItemData conversationListItemData, final boolean isLongClick, final ConversationListItemView conversationView)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
isConversationSelected(final String conversationId)355     public boolean isConversationSelected(final String conversationId) {
356         return mHost.isConversationSelected(conversationId);
357     }
358 
359     @Override
isSwipeAnimatable()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
updateEmptyListUi(final boolean isEmpty)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
getSnackBarInteractions()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 
getNormalizedFabAnimator()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 
dismissFab()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 
showFab()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