1/*
2 * Copyright (C) 2013 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.dialer.list;
17
18import android.animation.Animator;
19import android.animation.AnimatorSet;
20import android.animation.ObjectAnimator;
21import android.app.Activity;
22import android.app.LoaderManager;
23import android.content.CursorLoader;
24import android.content.Loader;
25import android.content.res.Resources;
26import android.database.Cursor;
27import android.graphics.Rect;
28import android.net.Uri;
29import android.os.Bundle;
30import android.util.Log;
31import android.view.LayoutInflater;
32import android.view.View;
33import android.view.ViewGroup;
34import android.view.ViewTreeObserver;
35import android.view.animation.AnimationUtils;
36import android.view.animation.LayoutAnimationController;
37import android.widget.AbsListView;
38import android.widget.AdapterView;
39import android.widget.AdapterView.OnItemClickListener;
40import android.widget.ImageView;
41import android.widget.ListView;
42import android.widget.RelativeLayout;
43import android.widget.RelativeLayout.LayoutParams;
44
45import com.android.contacts.common.ContactPhotoManager;
46import com.android.contacts.common.ContactTileLoaderFactory;
47import com.android.contacts.common.list.ContactTileView;
48import com.android.contacts.common.list.OnPhoneNumberPickerActionListener;
49import com.android.dialer.R;
50import com.android.dialer.util.DialerUtils;
51import com.android.dialerbind.analytics.AnalyticsFragment;
52
53import java.util.ArrayList;
54import java.util.HashMap;
55
56/**
57 * This fragment displays the user's favorite/frequent contacts in a grid.
58 */
59public class SpeedDialFragment extends AnalyticsFragment implements OnItemClickListener,
60        PhoneFavoritesTileAdapter.OnDataSetChangedForAnimationListener {
61
62    /**
63     * By default, the animation code assumes that all items in a list view are of the same height
64     * when animating new list items into view (e.g. from the bottom of the screen into view).
65     * This can cause incorrect translation offsets when a item that is larger or smaller than
66     * other list item is removed from the list. This key is used to provide the actual height
67     * of the removed object so that the actual translation appears correct to the user.
68     */
69    private static final long KEY_REMOVED_ITEM_HEIGHT = Long.MAX_VALUE;
70
71    private static final String TAG = SpeedDialFragment.class.getSimpleName();
72    private static final boolean DEBUG = false;
73
74    private int mAnimationDuration;
75
76    /**
77     * Used with LoaderManager.
78     */
79    private static int LOADER_ID_CONTACT_TILE = 1;
80
81    public interface HostInterface {
82        public void setDragDropController(DragDropController controller);
83    }
84
85    private class ContactTileLoaderListener implements LoaderManager.LoaderCallbacks<Cursor> {
86        @Override
87        public CursorLoader onCreateLoader(int id, Bundle args) {
88            if (DEBUG) Log.d(TAG, "ContactTileLoaderListener#onCreateLoader.");
89            return ContactTileLoaderFactory.createStrequentPhoneOnlyLoader(getActivity());
90        }
91
92        @Override
93        public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
94            if (DEBUG) Log.d(TAG, "ContactTileLoaderListener#onLoadFinished");
95            mContactTileAdapter.setContactCursor(data);
96            setEmptyViewVisibility(mContactTileAdapter.getCount() == 0);
97        }
98
99        @Override
100        public void onLoaderReset(Loader<Cursor> loader) {
101            if (DEBUG) Log.d(TAG, "ContactTileLoaderListener#onLoaderReset. ");
102        }
103    }
104
105    private class ContactTileAdapterListener implements ContactTileView.Listener {
106        @Override
107        public void onContactSelected(Uri contactUri, Rect targetRect) {
108            if (mPhoneNumberPickerActionListener != null) {
109                mPhoneNumberPickerActionListener.onPickPhoneNumberAction(contactUri);
110            }
111        }
112
113        @Override
114        public void onCallNumberDirectly(String phoneNumber) {
115            if (mPhoneNumberPickerActionListener != null) {
116                mPhoneNumberPickerActionListener.onCallNumberDirectly(phoneNumber);
117            }
118        }
119
120        @Override
121        public int getApproximateTileWidth() {
122            return getView().getWidth();
123        }
124    }
125
126    private class ScrollListener implements ListView.OnScrollListener {
127        @Override
128        public void onScroll(AbsListView view,
129                int firstVisibleItem, int visibleItemCount, int totalItemCount) {
130            if (mActivityScrollListener != null) {
131                mActivityScrollListener.onListFragmentScroll(firstVisibleItem, visibleItemCount,
132                    totalItemCount);
133            }
134        }
135
136        @Override
137        public void onScrollStateChanged(AbsListView view, int scrollState) {
138            mActivityScrollListener.onListFragmentScrollStateChange(scrollState);
139        }
140    }
141
142    private OnPhoneNumberPickerActionListener mPhoneNumberPickerActionListener;
143
144    private OnListFragmentScrolledListener mActivityScrollListener;
145    private PhoneFavoritesTileAdapter mContactTileAdapter;
146
147    private View mParentView;
148
149    private PhoneFavoriteListView mListView;
150
151    private View mContactTileFrame;
152
153    private TileInteractionTeaserView mTileInteractionTeaserView;
154
155    private final HashMap<Long, Integer> mItemIdTopMap = new HashMap<Long, Integer>();
156    private final HashMap<Long, Integer> mItemIdLeftMap = new HashMap<Long, Integer>();
157
158    /**
159     * Layout used when there are no favorites.
160     */
161    private View mEmptyView;
162
163    private final ContactTileView.Listener mContactTileAdapterListener =
164            new ContactTileAdapterListener();
165    private final LoaderManager.LoaderCallbacks<Cursor> mContactTileLoaderListener =
166            new ContactTileLoaderListener();
167    private final ScrollListener mScrollListener = new ScrollListener();
168
169    @Override
170    public void onAttach(Activity activity) {
171        if (DEBUG) Log.d(TAG, "onAttach()");
172        super.onAttach(activity);
173
174        // Construct two base adapters which will become part of PhoneFavoriteMergedAdapter.
175        // We don't construct the resultant adapter at this moment since it requires LayoutInflater
176        // that will be available on onCreateView().
177        mContactTileAdapter = new PhoneFavoritesTileAdapter(activity, mContactTileAdapterListener,
178                this);
179        mContactTileAdapter.setPhotoLoader(ContactPhotoManager.getInstance(activity));
180    }
181
182    @Override
183    public void onCreate(Bundle savedState) {
184        if (DEBUG) Log.d(TAG, "onCreate()");
185        super.onCreate(savedState);
186
187        mAnimationDuration = getResources().getInteger(R.integer.fade_duration);
188    }
189
190    @Override
191    public void onResume() {
192        super.onResume();
193
194        getLoaderManager().getLoader(LOADER_ID_CONTACT_TILE).forceLoad();
195    }
196
197    @Override
198    public View onCreateView(LayoutInflater inflater, ViewGroup container,
199            Bundle savedInstanceState) {
200        mParentView = inflater.inflate(R.layout.speed_dial_fragment, container, false);
201
202        mListView = (PhoneFavoriteListView) mParentView.findViewById(R.id.contact_tile_list);
203        mListView.setOnItemClickListener(this);
204        mListView.setVerticalScrollBarEnabled(false);
205        mListView.setVerticalScrollbarPosition(View.SCROLLBAR_POSITION_RIGHT);
206        mListView.setScrollBarStyle(ListView.SCROLLBARS_OUTSIDE_OVERLAY);
207        mListView.getDragDropController().addOnDragDropListener(mContactTileAdapter);
208
209        final ImageView dragShadowOverlay =
210                (ImageView) getActivity().findViewById(R.id.contact_tile_drag_shadow_overlay);
211        mListView.setDragShadowOverlay(dragShadowOverlay);
212
213        final Resources resources = getResources();
214        mEmptyView = mParentView.findViewById(R.id.empty_list_view);
215        DialerUtils.configureEmptyListView(
216                mEmptyView, R.drawable.empty_speed_dial, R.string.speed_dial_empty, getResources());
217
218        mContactTileFrame = mParentView.findViewById(R.id.contact_tile_frame);
219
220        mTileInteractionTeaserView = (TileInteractionTeaserView) inflater.inflate(
221                R.layout.tile_interactions_teaser_view, mListView, false);
222
223        final LayoutAnimationController controller = new LayoutAnimationController(
224                AnimationUtils.loadAnimation(getActivity(), android.R.anim.fade_in));
225        controller.setDelay(0);
226        mListView.setLayoutAnimation(controller);
227        mListView.setAdapter(mContactTileAdapter);
228
229        mListView.setOnScrollListener(mScrollListener);
230        mListView.setFastScrollEnabled(false);
231        mListView.setFastScrollAlwaysVisible(false);
232
233        return mParentView;
234    }
235
236    public boolean hasFrequents() {
237        if (mContactTileAdapter == null) return false;
238        return mContactTileAdapter.getNumFrequents() > 0;
239    }
240
241    /* package */ void setEmptyViewVisibility(final boolean visible) {
242        final int previousVisibility = mEmptyView.getVisibility();
243        final int newVisibility = visible ? View.VISIBLE : View.GONE;
244
245        if (previousVisibility != newVisibility) {
246            final RelativeLayout.LayoutParams params = (LayoutParams) mContactTileFrame
247                    .getLayoutParams();
248            params.height = visible ? LayoutParams.WRAP_CONTENT : LayoutParams.MATCH_PARENT;
249            mContactTileFrame.setLayoutParams(params);
250            mEmptyView.setVisibility(newVisibility);
251        }
252    }
253
254    @Override
255    public void onStart() {
256        super.onStart();
257
258        final Activity activity = getActivity();
259
260        try {
261            mActivityScrollListener = (OnListFragmentScrolledListener) activity;
262        } catch (ClassCastException e) {
263            throw new ClassCastException(activity.toString()
264                    + " must implement OnListFragmentScrolledListener");
265        }
266
267        try {
268            OnDragDropListener listener = (OnDragDropListener) activity;
269            mListView.getDragDropController().addOnDragDropListener(listener);
270            ((HostInterface) activity).setDragDropController(mListView.getDragDropController());
271        } catch (ClassCastException e) {
272            throw new ClassCastException(activity.toString()
273                    + " must implement OnDragDropListener and HostInterface");
274        }
275
276        try {
277            mPhoneNumberPickerActionListener = (OnPhoneNumberPickerActionListener) activity;
278        } catch (ClassCastException e) {
279            throw new ClassCastException(activity.toString()
280                    + " must implement PhoneFavoritesFragment.listener");
281        }
282
283        // Use initLoader() instead of restartLoader() to refraining unnecessary reload.
284        // This method call implicitly assures ContactTileLoaderListener's onLoadFinished() will
285        // be called, on which we'll check if "all" contacts should be reloaded again or not.
286        getLoaderManager().initLoader(LOADER_ID_CONTACT_TILE, null, mContactTileLoaderListener);
287    }
288
289    /**
290     * {@inheritDoc}
291     *
292     * This is only effective for elements provided by {@link #mContactTileAdapter}.
293     * {@link #mContactTileAdapter} has its own logic for click events.
294     */
295    @Override
296    public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
297        final int contactTileAdapterCount = mContactTileAdapter.getCount();
298        if (position <= contactTileAdapterCount) {
299            Log.e(TAG, "onItemClick() event for unexpected position. "
300                    + "The position " + position + " is before \"all\" section. Ignored.");
301        }
302    }
303
304    /**
305     * Cache the current view offsets into memory. Once a relayout of views in the ListView
306     * has happened due to a dataset change, the cached offsets are used to create animations
307     * that slide views from their previous positions to their new ones, to give the appearance
308     * that the views are sliding into their new positions.
309     */
310    private void saveOffsets(int removedItemHeight) {
311        final int firstVisiblePosition = mListView.getFirstVisiblePosition();
312        if (DEBUG) {
313            Log.d(TAG, "Child count : " + mListView.getChildCount());
314        }
315        for (int i = 0; i < mListView.getChildCount(); i++) {
316            final View child = mListView.getChildAt(i);
317            final int position = firstVisiblePosition + i;
318            final long itemId = mContactTileAdapter.getItemId(position);
319            if (DEBUG) {
320                Log.d(TAG, "Saving itemId: " + itemId + " for listview child " + i + " Top: "
321                        + child.getTop());
322            }
323            mItemIdTopMap.put(itemId, child.getTop());
324            mItemIdLeftMap.put(itemId, child.getLeft());
325        }
326
327        mItemIdTopMap.put(KEY_REMOVED_ITEM_HEIGHT, removedItemHeight);
328    }
329
330    /*
331     * Performs animations for the gridView
332     */
333    private void animateGridView(final long... idsInPlace) {
334        if (mItemIdTopMap.isEmpty()) {
335            // Don't do animations if the database is being queried for the first time and
336            // the previous item offsets have not been cached, or the user hasn't done anything
337            // (dragging, swiping etc) that requires an animation.
338            return;
339        }
340
341        final ViewTreeObserver observer = mListView.getViewTreeObserver();
342        observer.addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
343            @SuppressWarnings("unchecked")
344            @Override
345            public boolean onPreDraw() {
346                observer.removeOnPreDrawListener(this);
347                final int firstVisiblePosition = mListView.getFirstVisiblePosition();
348                final AnimatorSet animSet = new AnimatorSet();
349                final ArrayList<Animator> animators = new ArrayList<Animator>();
350                for (int i = 0; i < mListView.getChildCount(); i++) {
351                    final View child = mListView.getChildAt(i);
352                    int position = firstVisiblePosition + i;
353
354                    final long itemId = mContactTileAdapter.getItemId(position);
355
356                    if (containsId(idsInPlace, itemId)) {
357                        animators.add(ObjectAnimator.ofFloat(
358                                child, "alpha", 0.0f, 1.0f));
359                        break;
360                    } else {
361                        Integer startTop = mItemIdTopMap.get(itemId);
362                        Integer startLeft = mItemIdLeftMap.get(itemId);
363                        final int top = child.getTop();
364                        final int left = child.getLeft();
365                        int deltaX = 0;
366                        int deltaY = 0;
367
368                        if (startLeft != null) {
369                            if (startLeft != left) {
370                                deltaX = startLeft - left;
371                                animators.add(ObjectAnimator.ofFloat(
372                                        child, "translationX", deltaX, 0.0f));
373                            }
374                        }
375
376                        if (startTop != null) {
377                            if (startTop != top) {
378                                deltaY = startTop - top;
379                                animators.add(ObjectAnimator.ofFloat(
380                                        child, "translationY", deltaY, 0.0f));
381                            }
382                        }
383
384                        if (DEBUG) {
385                            Log.d(TAG, "Found itemId: " + itemId + " for listview child " + i +
386                                    " Top: " + top +
387                                    " Delta: " + deltaY);
388                        }
389                    }
390                }
391
392                if (animators.size() > 0) {
393                    animSet.setDuration(mAnimationDuration).playTogether(animators);
394                    animSet.start();
395                }
396
397                mItemIdTopMap.clear();
398                mItemIdLeftMap.clear();
399                return true;
400            }
401        });
402    }
403
404    private boolean containsId(long[] ids, long target) {
405        // Linear search on array is fine because this is typically only 0-1 elements long
406        for (int i = 0; i < ids.length; i++) {
407            if (ids[i] == target) {
408                return true;
409            }
410        }
411        return false;
412    }
413
414    @Override
415    public void onDataSetChangedForAnimation(long... idsInPlace) {
416        animateGridView(idsInPlace);
417    }
418
419    @Override
420    public void cacheOffsetsForDatasetChange() {
421        saveOffsets(0);
422    }
423
424    public AbsListView getListView() {
425        return mListView;
426    }
427}
428