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