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 */ 16 package com.android.dialer.list; 17 18 import android.animation.ObjectAnimator; 19 import android.content.ContentUris; 20 import android.content.ContentValues; 21 import android.content.Context; 22 import android.content.res.Resources; 23 import android.database.Cursor; 24 import android.net.Uri; 25 import android.provider.ContactsContract.CommonDataKinds.Phone; 26 import android.provider.ContactsContract.Contacts; 27 import android.provider.ContactsContract.PinnedPositions; 28 import android.text.TextUtils; 29 import android.util.Log; 30 import android.util.LongSparseArray; 31 import android.view.MotionEvent; 32 import android.view.View; 33 import android.view.ViewConfiguration; 34 import android.view.ViewGroup; 35 import android.widget.BaseAdapter; 36 import android.widget.FrameLayout; 37 38 import com.android.contacts.common.ContactPhotoManager; 39 import com.android.contacts.common.ContactTileLoaderFactory; 40 import com.android.contacts.common.R; 41 import com.android.contacts.common.list.ContactEntry; 42 import com.android.contacts.common.list.ContactTileAdapter.DisplayType; 43 import com.android.contacts.common.list.ContactTileView; 44 import com.android.dialer.list.SwipeHelper.OnItemGestureListener; 45 import com.android.dialer.list.SwipeHelper.SwipeHelperCallback; 46 import com.android.internal.annotations.VisibleForTesting; 47 48 import com.google.common.collect.ComparisonChain; 49 50 import java.util.ArrayList; 51 import java.util.Comparator; 52 import java.util.HashMap; 53 import java.util.LinkedList; 54 import java.util.List; 55 import java.util.PriorityQueue; 56 57 /** 58 * Also allows for a configurable number of columns as well as a maximum row of tiled contacts. 59 * 60 * This adapter has been rewritten to only support a maximum of one row for favorites. 61 * 62 */ 63 public class PhoneFavoritesTileAdapter extends BaseAdapter implements 64 SwipeHelper.OnItemGestureListener, PhoneFavoriteListView.OnDragDropListener { 65 private static final String TAG = PhoneFavoritesTileAdapter.class.getSimpleName(); 66 private static final boolean DEBUG = false; 67 68 public static final int ROW_LIMIT_DEFAULT = 1; 69 70 private ContactTileView.Listener mListener; 71 private OnDataSetChangedForAnimationListener mDataSetChangedListener; 72 73 private Context mContext; 74 private Resources mResources; 75 76 /** Contact data stored in cache. This is used to populate the associated view. */ 77 protected ArrayList<ContactEntry> mContactEntries = null; 78 /** Back up of the temporarily removed Contact during dragging. */ 79 private ContactEntry mDraggedEntry = null; 80 /** Position of the temporarily removed contact in the cache. */ 81 private int mDraggedEntryIndex = -1; 82 /** New position of the temporarily removed contact in the cache. */ 83 private int mDropEntryIndex = -1; 84 /** New position of the temporarily entered contact in the cache. */ 85 private int mDragEnteredEntryIndex = -1; 86 /** Position of the contact pending removal. */ 87 private int mPotentialRemoveEntryIndex = -1; 88 private long mIdToKeepInPlace = -1; 89 90 private boolean mAwaitingRemove = false; 91 92 private ContactPhotoManager mPhotoManager; 93 protected int mNumFrequents; 94 protected int mNumStarred; 95 96 protected int mColumnCount; 97 private int mMaxTiledRows = ROW_LIMIT_DEFAULT; 98 private int mStarredIndex; 99 100 protected int mIdIndex; 101 protected int mLookupIndex; 102 protected int mPhotoUriIndex; 103 protected int mNameIndex; 104 protected int mPresenceIndex; 105 protected int mStatusIndex; 106 107 private int mPhoneNumberIndex; 108 private int mPhoneNumberTypeIndex; 109 private int mPhoneNumberLabelIndex; 110 private int mIsDefaultNumberIndex; 111 protected int mPinnedIndex; 112 protected int mContactIdIndex; 113 114 private final int mPaddingInPixels; 115 116 /** Indicates whether a drag is in process. */ 117 private boolean mInDragging = false; 118 119 public static final int PIN_LIMIT = 20; 120 121 /** 122 * The soft limit on how many contact tiles to show. 123 * NOTE This soft limit would not restrict the number of starred contacts to show, rather 124 * 1. If the count of starred contacts is less than this limit, show 20 tiles total. 125 * 2. If the count of starred contacts is more than or equal to this limit, 126 * show all starred tiles and no frequents. 127 */ 128 private static final int TILES_SOFT_LIMIT = 20; 129 130 final Comparator<ContactEntry> mContactEntryComparator = new Comparator<ContactEntry>() { 131 @Override 132 public int compare(ContactEntry lhs, ContactEntry rhs) { 133 return ComparisonChain.start() 134 .compare(lhs.pinned, rhs.pinned) 135 .compare(lhs.name, rhs.name) 136 .result(); 137 } 138 }; 139 140 public interface OnDataSetChangedForAnimationListener { 141 public void onDataSetChangedForAnimation(long... idsInPlace); 142 public void cacheOffsetsForDatasetChange(); 143 }; 144 145 public PhoneFavoritesTileAdapter(Context context, ContactTileView.Listener listener, 146 OnDataSetChangedForAnimationListener dataSetChangedListener, 147 int numCols, int maxTiledRows) { 148 mDataSetChangedListener = dataSetChangedListener; 149 mListener = listener; 150 mContext = context; 151 mResources = context.getResources(); 152 mColumnCount = numCols; 153 mNumFrequents = 0; 154 mMaxTiledRows = maxTiledRows; 155 mContactEntries = new ArrayList<ContactEntry>(); 156 // Converting padding in dips to padding in pixels 157 mPaddingInPixels = mContext.getResources() 158 .getDimensionPixelSize(R.dimen.contact_tile_divider_padding); 159 160 bindColumnIndices(); 161 } 162 163 public void setPhotoLoader(ContactPhotoManager photoLoader) { 164 mPhotoManager = photoLoader; 165 } 166 167 public void setMaxRowCount(int maxRows) { 168 mMaxTiledRows = maxRows; 169 } 170 171 public void setColumnCount(int columnCount) { 172 mColumnCount = columnCount; 173 } 174 175 /** 176 * Indicates whether a drag is in process. 177 * 178 * @param inDragging Boolean variable indicating whether there is a drag in process. 179 */ 180 public void setInDragging(boolean inDragging) { 181 mInDragging = inDragging; 182 } 183 184 /** Gets whether the drag is in process. */ 185 public boolean getInDragging() { 186 return mInDragging; 187 } 188 189 /** 190 * Sets the column indices for expected {@link Cursor} 191 * based on {@link DisplayType}. 192 */ 193 protected void bindColumnIndices() { 194 mIdIndex = ContactTileLoaderFactory.CONTACT_ID; 195 mLookupIndex = ContactTileLoaderFactory.LOOKUP_KEY; 196 mPhotoUriIndex = ContactTileLoaderFactory.PHOTO_URI; 197 mNameIndex = ContactTileLoaderFactory.DISPLAY_NAME; 198 mStarredIndex = ContactTileLoaderFactory.STARRED; 199 mPresenceIndex = ContactTileLoaderFactory.CONTACT_PRESENCE; 200 mStatusIndex = ContactTileLoaderFactory.CONTACT_STATUS; 201 202 mPhoneNumberIndex = ContactTileLoaderFactory.PHONE_NUMBER; 203 mPhoneNumberTypeIndex = ContactTileLoaderFactory.PHONE_NUMBER_TYPE; 204 mPhoneNumberLabelIndex = ContactTileLoaderFactory.PHONE_NUMBER_LABEL; 205 mIsDefaultNumberIndex = ContactTileLoaderFactory.IS_DEFAULT_NUMBER; 206 mPinnedIndex = ContactTileLoaderFactory.PINNED; 207 mContactIdIndex = ContactTileLoaderFactory.CONTACT_ID_FOR_DATA; 208 } 209 210 /** 211 * Gets the number of frequents from the passed in cursor. 212 * 213 * This methods is needed so the GroupMemberTileAdapter can override this. 214 * 215 * @param cursor The cursor to get number of frequents from. 216 */ 217 protected void saveNumFrequentsFromCursor(Cursor cursor) { 218 mNumFrequents = cursor.getCount() - mNumStarred; 219 } 220 221 /** 222 * Creates {@link ContactTileView}s for each item in {@link Cursor}. 223 * 224 * Else use {@link ContactTileLoaderFactory} 225 */ 226 public void setContactCursor(Cursor cursor) { 227 if (cursor != null && !cursor.isClosed()) { 228 mNumStarred = getNumStarredContacts(cursor); 229 if (mAwaitingRemove) { 230 mDataSetChangedListener.cacheOffsetsForDatasetChange(); 231 } 232 233 saveNumFrequentsFromCursor(cursor); 234 saveCursorToCache(cursor); 235 // cause a refresh of any views that rely on this data 236 notifyDataSetChanged(); 237 // about to start redraw 238 if (mIdToKeepInPlace != -1) { 239 mDataSetChangedListener.onDataSetChangedForAnimation(mIdToKeepInPlace); 240 } else { 241 mDataSetChangedListener.onDataSetChangedForAnimation(); 242 } 243 mIdToKeepInPlace = -1; 244 } 245 } 246 247 /** 248 * Saves the cursor data to the cache, to speed up UI changes. 249 * 250 * @param cursor Returned cursor with data to populate the view. 251 */ 252 private void saveCursorToCache(Cursor cursor) { 253 mContactEntries.clear(); 254 255 cursor.moveToPosition(-1); 256 257 final LongSparseArray<Object> duplicates = new LongSparseArray<Object>(cursor.getCount()); 258 259 // Track the length of {@link #mContactEntries} and compare to {@link #TILES_SOFT_LIMIT}. 260 int counter = 0; 261 262 while (cursor.moveToNext()) { 263 264 final int starred = cursor.getInt(mStarredIndex); 265 final long id; 266 267 // We display a maximum of TILES_SOFT_LIMIT contacts, or the total number of starred 268 // whichever is greater. 269 if (starred < 1 && counter >= TILES_SOFT_LIMIT) { 270 break; 271 } else { 272 id = cursor.getLong(mContactIdIndex); 273 } 274 275 final ContactEntry existing = (ContactEntry) duplicates.get(id); 276 if (existing != null) { 277 // Check if the existing number is a default number. If not, clear the phone number 278 // and label fields so that the disambiguation dialog will show up. 279 if (!existing.isDefaultNumber) { 280 existing.phoneLabel = null; 281 existing.phoneNumber = null; 282 } 283 continue; 284 } 285 286 final String photoUri = cursor.getString(mPhotoUriIndex); 287 final String lookupKey = cursor.getString(mLookupIndex); 288 final int pinned = cursor.getInt(mPinnedIndex); 289 final String name = cursor.getString(mNameIndex); 290 final boolean isStarred = cursor.getInt(mStarredIndex) > 0; 291 final boolean isDefaultNumber = cursor.getInt(mIsDefaultNumberIndex) > 0; 292 293 final ContactEntry contact = new ContactEntry(); 294 295 contact.id = id; 296 contact.name = (!TextUtils.isEmpty(name)) ? name : 297 mResources.getString(R.string.missing_name); 298 contact.photoUri = (photoUri != null ? Uri.parse(photoUri) : null); 299 contact.lookupKey = ContentUris.withAppendedId( 300 Uri.withAppendedPath(Contacts.CONTENT_LOOKUP_URI, lookupKey), id); 301 contact.isFavorite = isStarred; 302 contact.isDefaultNumber = isDefaultNumber; 303 304 // Set phone number and label 305 final int phoneNumberType = cursor.getInt(mPhoneNumberTypeIndex); 306 final String phoneNumberCustomLabel = cursor.getString(mPhoneNumberLabelIndex); 307 contact.phoneLabel = (String) Phone.getTypeLabel(mResources, phoneNumberType, 308 phoneNumberCustomLabel); 309 contact.phoneNumber = cursor.getString(mPhoneNumberIndex); 310 311 contact.pinned = pinned; 312 mContactEntries.add(contact); 313 314 duplicates.put(id, contact); 315 316 counter++; 317 } 318 319 mAwaitingRemove = false; 320 321 arrangeContactsByPinnedPosition(mContactEntries); 322 323 notifyDataSetChanged(); 324 } 325 326 /** 327 * Iterates over the {@link Cursor} 328 * Returns position of the first NON Starred Contact 329 * Returns -1 if {@link DisplayType#STARRED_ONLY} 330 * Returns 0 if {@link DisplayType#FREQUENT_ONLY} 331 */ 332 protected int getNumStarredContacts(Cursor cursor) { 333 cursor.moveToPosition(-1); 334 while (cursor.moveToNext()) { 335 if (cursor.getInt(mStarredIndex) == 0) { 336 return cursor.getPosition(); 337 } 338 } 339 340 // There are not NON Starred contacts in cursor 341 // Set divider positon to end 342 return cursor.getCount(); 343 } 344 345 /** 346 * Loads a contact from the cached list. 347 * 348 * @param position Position of the Contact. 349 * @return Contact at the requested position. 350 */ 351 protected ContactEntry getContactEntryFromCache(int position) { 352 if (mContactEntries.size() <= position) return null; 353 return mContactEntries.get(position); 354 } 355 356 /** 357 * Returns the number of frequents that will be displayed in the list. 358 */ 359 public int getNumFrequents() { 360 return mNumFrequents; 361 } 362 363 @Override 364 public int getCount() { 365 if (mContactEntries == null || mContactEntries.isEmpty()) { 366 return 0; 367 } 368 369 int total = mContactEntries.size(); 370 // The number of contacts that don't show up as tiles 371 final int nonTiledRows = Math.max(0, total - getMaxContactsInTiles()); 372 // The number of tiled rows 373 final int tiledRows = getRowCount(total - nonTiledRows); 374 return nonTiledRows + tiledRows; 375 } 376 377 public int getMaxTiledRows() { 378 return mMaxTiledRows; 379 } 380 381 /** 382 * Returns the number of rows required to show the provided number of entries 383 * with the current number of columns. 384 */ 385 protected int getRowCount(int entryCount) { 386 if (entryCount == 0) return 0; 387 final int nonLimitedRows = ((entryCount - 1) / mColumnCount) + 1; 388 return Math.min(mMaxTiledRows, nonLimitedRows); 389 } 390 391 private int getMaxContactsInTiles() { 392 return mColumnCount * mMaxTiledRows; 393 } 394 395 public int getRowIndex(int entryIndex) { 396 if (entryIndex < mMaxTiledRows * mColumnCount) { 397 return entryIndex / mColumnCount; 398 } else { 399 return entryIndex - mMaxTiledRows * mColumnCount + mMaxTiledRows; 400 } 401 } 402 403 public int getColumnCount() { 404 return mColumnCount; 405 } 406 407 /** 408 * Returns an ArrayList of the {@link ContactEntry}s that are to appear 409 * on the row for the given position. 410 */ 411 @Override 412 public ArrayList<ContactEntry> getItem(int position) { 413 ArrayList<ContactEntry> resultList = new ArrayList<ContactEntry>(mColumnCount); 414 415 final int entryIndex = getFirstContactEntryIndexForPosition(position); 416 417 final int viewType = getItemViewType(position); 418 419 final int columnCount; 420 if (viewType == ViewTypes.TOP) { 421 columnCount = mColumnCount; 422 } else { 423 columnCount = 1; 424 } 425 426 for (int i = 0; i < columnCount; i++) { 427 final ContactEntry entry = getContactEntryFromCache(entryIndex + i); 428 if (entry == null) break; // less than mColumnCount contacts 429 resultList.add(entry); 430 } 431 432 return resultList; 433 } 434 435 /* 436 * Given a position in the adapter, returns the index of the first contact entry that is to be 437 * in that row. 438 */ 439 private int getFirstContactEntryIndexForPosition(int position) { 440 final int maxContactsInTiles = getMaxContactsInTiles(); 441 if (position < getRowCount(maxContactsInTiles)) { 442 // Contacts that appear as tiles 443 return position * mColumnCount; 444 } else { 445 // Contacts that appear as rows 446 // The actual position of the contact in the cursor is simply total the number of 447 // tiled contacts + the given position 448 return maxContactsInTiles + position - mMaxTiledRows; 449 } 450 } 451 452 /** 453 * For the top row of tiled contacts, the item id is the position of the row of 454 * contacts. 455 * For frequent contacts, the item id is the maximum number of rows of tiled contacts + 456 * the actual contact id. Since contact ids are always greater than 0, this guarantees that 457 * all items within this adapter will always have unique ids. 458 */ 459 @Override 460 public long getItemId(int position) { 461 if (getItemViewType(position) == ViewTypes.FREQUENT) { 462 return getAdjustedItemId(getItem(position).get(0).id); 463 } else { 464 return position; 465 } 466 } 467 468 /** 469 * Calculates the stable itemId for a particular entry based on its contactID 470 */ 471 public long getAdjustedItemId(long id) { 472 return mMaxTiledRows + id; 473 } 474 475 @Override 476 public boolean hasStableIds() { 477 return true; 478 } 479 480 @Override 481 482 public boolean areAllItemsEnabled() { 483 // No dividers, so all items are enabled. 484 return true; 485 } 486 487 @Override 488 public boolean isEnabled(int position) { 489 return getCount() > 0; 490 } 491 492 @Override 493 public void notifyDataSetChanged() { 494 if (DEBUG) { 495 Log.v(TAG, "notifyDataSetChanged"); 496 } 497 super.notifyDataSetChanged(); 498 } 499 500 @Override 501 public View getView(int position, View convertView, ViewGroup parent) { 502 if (DEBUG) { 503 Log.v(TAG, "get view for " + String.valueOf(position)); 504 } 505 506 int itemViewType = getItemViewType(position); 507 508 ContactTileRow contactTileRowView = null; 509 510 if (convertView instanceof ContactTileRow) { 511 contactTileRowView = (ContactTileRow) convertView; 512 } 513 514 ArrayList<ContactEntry> contactList = getItem(position); 515 516 if (contactTileRowView == null) { 517 // Creating new row if needed 518 contactTileRowView = new ContactTileRow(mContext, itemViewType, position); 519 } 520 521 contactTileRowView.configureRow(contactList, position, position == getCount() - 1); 522 523 return contactTileRowView; 524 } 525 526 private int getLayoutResourceId(int viewType) { 527 switch (viewType) { 528 case ViewTypes.FREQUENT: 529 return R.layout.phone_favorite_regular_row_view; 530 case ViewTypes.TOP: 531 return R.layout.phone_favorite_tile_view; 532 default: 533 throw new IllegalArgumentException("Unrecognized viewType " + viewType); 534 } 535 } 536 @Override 537 public int getViewTypeCount() { 538 return ViewTypes.COUNT; 539 } 540 541 @Override 542 public int getItemViewType(int position) { 543 if (position < getRowCount(getMaxContactsInTiles())) { 544 return ViewTypes.TOP; 545 } else { 546 return ViewTypes.FREQUENT; 547 } 548 } 549 550 /** 551 * Temporarily removes a contact from the list for UI refresh. Stores data for this contact 552 * in the back-up variable. 553 * 554 * @param index Position of the contact to be removed. 555 */ 556 public void popContactEntry(int index) { 557 if (isIndexInBound(index)) { 558 mDraggedEntry = mContactEntries.get(index); 559 mDraggedEntryIndex = index; 560 mDragEnteredEntryIndex = index; 561 markDropArea(mDragEnteredEntryIndex); 562 } 563 } 564 565 /** 566 * @param itemIndex Position of the contact in {@link #mContactEntries}. 567 * @return True if the given index is valid for {@link #mContactEntries}. 568 */ 569 private boolean isIndexInBound(int itemIndex) { 570 return itemIndex >= 0 && itemIndex < mContactEntries.size(); 571 } 572 573 /** 574 * Mark the tile as drop area by given the item index in {@link #mContactEntries}. 575 * 576 * @param itemIndex Position of the contact in {@link #mContactEntries}. 577 */ 578 private void markDropArea(int itemIndex) { 579 if (isIndexInBound(mDragEnteredEntryIndex) && isIndexInBound(itemIndex)) { 580 mDataSetChangedListener.cacheOffsetsForDatasetChange(); 581 // Remove the old placeholder item and place the new placeholder item. 582 final int oldIndex = mDragEnteredEntryIndex; 583 mContactEntries.remove(mDragEnteredEntryIndex); 584 mDragEnteredEntryIndex = itemIndex; 585 mContactEntries.add(mDragEnteredEntryIndex, ContactEntry.BLANK_ENTRY); 586 ContactEntry.BLANK_ENTRY.id = mDraggedEntry.id; 587 mDataSetChangedListener.onDataSetChangedForAnimation(); 588 notifyDataSetChanged(); 589 } 590 } 591 592 /** 593 * Drops the temporarily removed contact to the desired location in the list. 594 */ 595 public void handleDrop() { 596 boolean changed = false; 597 if (mDraggedEntry != null) { 598 if (isIndexInBound(mDragEnteredEntryIndex) && 599 mDragEnteredEntryIndex != mDraggedEntryIndex) { 600 // Don't add the ContactEntry here (to prevent a double animation from occuring). 601 // When we receive a new cursor the list of contact entries will automatically be 602 // populated with the dragged ContactEntry at the correct spot. 603 mDropEntryIndex = mDragEnteredEntryIndex; 604 mContactEntries.set(mDropEntryIndex, mDraggedEntry); 605 mIdToKeepInPlace = getAdjustedItemId(mDraggedEntry.id); 606 mDataSetChangedListener.cacheOffsetsForDatasetChange(); 607 changed = true; 608 } else if (isIndexInBound(mDraggedEntryIndex)) { 609 // If {@link #mDragEnteredEntryIndex} is invalid, 610 // falls back to the original position of the contact. 611 mContactEntries.remove(mDragEnteredEntryIndex); 612 mContactEntries.add(mDraggedEntryIndex, mDraggedEntry); 613 mDropEntryIndex = mDraggedEntryIndex; 614 notifyDataSetChanged(); 615 } 616 617 if (changed && mDropEntryIndex < PIN_LIMIT) { 618 final ContentValues cv = getReflowedPinnedPositions(mContactEntries, mDraggedEntry, 619 mDraggedEntryIndex, mDropEntryIndex); 620 final Uri pinUri = PinnedPositions.UPDATE_URI.buildUpon().build(); 621 // update the database here with the new pinned positions 622 mContext.getContentResolver().update(pinUri, cv, null, null); 623 } 624 mDraggedEntry = null; 625 } 626 } 627 628 /** 629 * Invoked when the dragged item is dropped to unsupported location. We will then move the 630 * contact back to where it was dragged from. 631 */ 632 public void dropToUnsupportedView() { 633 if (isIndexInBound(mDragEnteredEntryIndex)) { 634 mContactEntries.remove(mDragEnteredEntryIndex); 635 mContactEntries.add(mDraggedEntryIndex, mDraggedEntry); 636 notifyDataSetChanged(); 637 } 638 } 639 640 /** 641 * Sets an item to for pending removal. If the user does not click the undo button, the item 642 * will be removed at the next interaction. 643 * 644 * @param index Index of the item to be removed. 645 */ 646 public void setPotentialRemoveEntryIndex(int index) { 647 mPotentialRemoveEntryIndex = index; 648 } 649 650 /** 651 * Removes a contact entry from the list. 652 * 653 * @return True is an item is removed. False is there is no item to be removed. 654 */ 655 public boolean removePendingContactEntry() { 656 boolean removed = false; 657 if (isIndexInBound(mPotentialRemoveEntryIndex)) { 658 final ContactEntry entry = mContactEntries.get(mPotentialRemoveEntryIndex); 659 unstarAndUnpinContact(entry.lookupKey); 660 removed = true; 661 mAwaitingRemove = true; 662 } 663 cleanTempVariables(); 664 return removed; 665 } 666 667 /** 668 * Resets the item for pending removal. 669 */ 670 public void undoPotentialRemoveEntryIndex() { 671 mPotentialRemoveEntryIndex = -1; 672 } 673 674 public boolean hasPotentialRemoveEntryIndex() { 675 return isIndexInBound(mPotentialRemoveEntryIndex); 676 } 677 678 /** 679 * Clears all temporary variables at a new interaction. 680 */ 681 public void cleanTempVariables() { 682 mDraggedEntryIndex = -1; 683 mDropEntryIndex = -1; 684 mDragEnteredEntryIndex = -1; 685 mDraggedEntry = null; 686 mPotentialRemoveEntryIndex = -1; 687 } 688 689 /** 690 * Acts as a row item composed of {@link ContactTileView} 691 * 692 */ 693 public class ContactTileRow extends FrameLayout implements SwipeHelperCallback { 694 public static final int CONTACT_ENTRY_INDEX_TAG = R.id.contact_entry_index_tag; 695 696 private int mItemViewType; 697 private int mLayoutResId; 698 private final int mRowPaddingStart; 699 private final int mRowPaddingEnd; 700 private final int mRowPaddingTop; 701 private final int mRowPaddingBottom; 702 private int mPosition; 703 private SwipeHelper mSwipeHelper; 704 private OnItemGestureListener mOnItemSwipeListener; 705 706 public ContactTileRow(Context context, int itemViewType, int position) { 707 super(context); 708 mItemViewType = itemViewType; 709 mLayoutResId = getLayoutResourceId(mItemViewType); 710 mPosition = position; 711 712 final Resources resources = mContext.getResources(); 713 714 if (mItemViewType == ViewTypes.TOP) { 715 // For tiled views, we still want padding to be set on the ContactTileRow. 716 // Otherwise the padding would be set around each of the tiles, which we don't want 717 mRowPaddingTop = resources.getDimensionPixelSize( 718 R.dimen.favorites_row_top_padding); 719 mRowPaddingBottom = resources.getDimensionPixelSize( 720 R.dimen.favorites_row_bottom_padding); 721 mRowPaddingStart = resources.getDimensionPixelSize( 722 R.dimen.favorites_row_start_padding); 723 mRowPaddingEnd = resources.getDimensionPixelSize( 724 R.dimen.favorites_row_end_padding); 725 726 setBackgroundResource(R.drawable.bottom_border_background); 727 } else { 728 // For row views, padding is set on the view itself. 729 mRowPaddingTop = 0; 730 mRowPaddingBottom = 0; 731 mRowPaddingStart = 0; 732 mRowPaddingEnd = 0; 733 } 734 735 setPaddingRelative(mRowPaddingStart, mRowPaddingTop, mRowPaddingEnd, 736 mRowPaddingBottom); 737 738 // Remove row (but not children) from accessibility node tree. 739 setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO); 740 741 if (mItemViewType == ViewTypes.FREQUENT) { 742 // ListView handles swiping for this item 743 SwipeHelper.setSwipeable(this, true); 744 } else if (mItemViewType == ViewTypes.TOP) { 745 // The contact tile row has its own swipe helpers, that makes each individual 746 // tile swipeable. 747 final float densityScale = getResources().getDisplayMetrics().density; 748 final float pagingTouchSlop = ViewConfiguration.get(context) 749 .getScaledPagingTouchSlop(); 750 mSwipeHelper = new SwipeHelper(context, SwipeHelper.X, this, densityScale, 751 pagingTouchSlop); 752 // Increase swipe thresholds for square tiles since they are relatively small. 753 mSwipeHelper.setChildSwipedFarEnoughFactor(0.9f); 754 mSwipeHelper.setChildSwipedFastEnoughFactor(0.1f); 755 mOnItemSwipeListener = PhoneFavoritesTileAdapter.this; 756 } 757 } 758 759 /** 760 * Configures the row to add {@link ContactEntry}s information to the views 761 */ 762 public void configureRow(ArrayList<ContactEntry> list, int position, boolean isLastRow) { 763 int columnCount = mItemViewType == ViewTypes.FREQUENT ? 1 : mColumnCount; 764 mPosition = position; 765 766 // Adding tiles to row and filling in contact information 767 for (int columnCounter = 0; columnCounter < columnCount; columnCounter++) { 768 ContactEntry entry = 769 columnCounter < list.size() ? list.get(columnCounter) : null; 770 addTileFromEntry(entry, columnCounter, isLastRow); 771 } 772 if (columnCount == 1) { 773 if (list.get(0) == ContactEntry.BLANK_ENTRY) { 774 setVisibility(View.INVISIBLE); 775 } else { 776 setVisibility(View.VISIBLE); 777 } 778 } 779 } 780 781 private void addTileFromEntry(ContactEntry entry, int childIndex, boolean isLastRow) { 782 final PhoneFavoriteTileView contactTile; 783 784 if (getChildCount() <= childIndex) { 785 786 contactTile = (PhoneFavoriteTileView) inflate(mContext, mLayoutResId, null); 787 // Note: the layoutparam set here is only actually used for FREQUENT. 788 // We override onMeasure() for STARRED and we don't care the layout param there. 789 final Resources resources = mContext.getResources(); 790 FrameLayout.LayoutParams params = new FrameLayout.LayoutParams( 791 ViewGroup.LayoutParams.WRAP_CONTENT, 792 ViewGroup.LayoutParams.WRAP_CONTENT); 793 794 params.setMargins( 795 resources.getDimensionPixelSize(R.dimen.detail_item_side_margin), 0, 796 resources.getDimensionPixelSize(R.dimen.detail_item_side_margin), 0); 797 contactTile.setLayoutParams(params); 798 contactTile.setPhotoManager(mPhotoManager); 799 contactTile.setListener(mListener); 800 addView(contactTile); 801 } else { 802 contactTile = (PhoneFavoriteTileView) getChildAt(childIndex); 803 } 804 contactTile.loadFromContact(entry); 805 806 int entryIndex = -1; 807 switch (mItemViewType) { 808 case ViewTypes.TOP: 809 // Setting divider visibilities 810 contactTile.setPaddingRelative(0, 0, 811 childIndex >= mColumnCount - 1 ? 0 : mPaddingInPixels, 0); 812 entryIndex = getFirstContactEntryIndexForPosition(mPosition) + childIndex; 813 SwipeHelper.setSwipeable(contactTile, false); 814 break; 815 case ViewTypes.FREQUENT: 816 contactTile.setHorizontalDividerVisibility( 817 isLastRow ? View.GONE : View.VISIBLE); 818 entryIndex = getFirstContactEntryIndexForPosition(mPosition); 819 SwipeHelper.setSwipeable(this, true); 820 break; 821 default: 822 break; 823 } 824 // tag the tile with the index of the contact entry it is associated with 825 if (entryIndex != -1) { 826 contactTile.setTag(CONTACT_ENTRY_INDEX_TAG, entryIndex); 827 } 828 contactTile.setupFavoriteContactCard(); 829 } 830 831 @Override 832 protected void onLayout(boolean changed, int left, int top, int right, int bottom) { 833 switch (mItemViewType) { 834 case ViewTypes.TOP: 835 onLayoutForTiles(); 836 return; 837 default: 838 super.onLayout(changed, left, top, right, bottom); 839 return; 840 } 841 } 842 843 private void onLayoutForTiles() { 844 final int count = getChildCount(); 845 846 // Just line up children horizontally. 847 int childLeft = getPaddingStart(); 848 for (int i = 0; i < count; i++) { 849 final View child = getChildAt(i); 850 851 // Note MeasuredWidth includes the padding. 852 final int childWidth = child.getMeasuredWidth(); 853 child.layout(childLeft, getPaddingTop(), childLeft + childWidth, 854 getPaddingTop() + child.getMeasuredHeight()); 855 childLeft += childWidth; 856 } 857 } 858 859 @Override 860 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 861 switch (mItemViewType) { 862 case ViewTypes.TOP: 863 onMeasureForTiles(widthMeasureSpec); 864 return; 865 default: 866 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 867 return; 868 } 869 } 870 871 private void onMeasureForTiles(int widthMeasureSpec) { 872 final int width = MeasureSpec.getSize(widthMeasureSpec); 873 874 final int childCount = getChildCount(); 875 if (childCount == 0) { 876 // Just in case... 877 setMeasuredDimension(width, 0); 878 return; 879 } 880 881 // 1. Calculate image size. 882 // = ([total width] - [total padding]) / [child count] 883 // 884 // 2. Set it to width/height of each children. 885 // If we have a remainder, some tiles will have 1 pixel larger width than its height. 886 // 887 // 3. Set the dimensions of itself. 888 // Let width = given width. 889 // Let height = image size + bottom paddding. 890 891 final int totalPaddingsInPixels = (mColumnCount - 1) * mPaddingInPixels 892 + mRowPaddingStart + mRowPaddingEnd; 893 894 // Preferred width / height for images (excluding the padding). 895 // The actual width may be 1 pixel larger than this if we have a remainder. 896 final int imageSize = (width - totalPaddingsInPixels) / mColumnCount; 897 final int remainder = width - (imageSize * mColumnCount) - totalPaddingsInPixels; 898 899 for (int i = 0; i < childCount; i++) { 900 final View child = getChildAt(i); 901 final int childWidth = imageSize + child.getPaddingRight() 902 // Compensate for the remainder 903 + (i < remainder ? 1 : 0); 904 final int childHeight = imageSize; 905 child.measure( 906 MeasureSpec.makeMeasureSpec(childWidth, MeasureSpec.EXACTLY), 907 MeasureSpec.makeMeasureSpec(childHeight, MeasureSpec.EXACTLY) 908 ); 909 } 910 setMeasuredDimension(width, imageSize + getPaddingTop() + getPaddingBottom()); 911 } 912 913 /** 914 * Gets the index of the item at the specified coordinates. 915 * 916 * @param itemX X-coordinate of the selected item. 917 * @param itemY Y-coordinate of the selected item. 918 * @return Index of the selected item in the cached array. 919 */ 920 public int getItemIndex(float itemX, float itemY) { 921 if (mPosition < mMaxTiledRows) { 922 if (DEBUG) { 923 Log.v(TAG, String.valueOf(itemX) + " " + String.valueOf(itemY)); 924 } 925 for (int i = 0; i < getChildCount(); ++i) { 926 /** If the row contains multiple tiles, checks each tile to see if the point 927 * is contained in the tile. */ 928 final View child = getChildAt(i); 929 /** The coordinates passed in are based on the ListView, 930 * translate for each child first */ 931 final int xInListView = child.getLeft() + getLeft(); 932 final int yInListView = child.getTop() + getTop(); 933 final int distanceX = (int) itemX - xInListView; 934 final int distanceY = (int) itemY - yInListView; 935 if ((distanceX > 0 && distanceX < child.getWidth()) && 936 (distanceY > 0 && distanceY < child.getHeight())) { 937 /** If the point is contained in the rectangle, computes the index of the 938 * item in the cached array. */ 939 return i + (mPosition) * mColumnCount; 940 } 941 } 942 } else { 943 /** If the selected item is one of the rows, compute the index. */ 944 return getRegularRowItemIndex(); 945 } 946 return -1; 947 } 948 949 /** 950 * Gets the index of the regular row item. 951 * 952 * @return Index of the selected item in the cached array. 953 */ 954 public int getRegularRowItemIndex() { 955 return (mPosition - mMaxTiledRows) + mColumnCount * mMaxTiledRows; 956 } 957 958 public PhoneFavoritesTileAdapter getTileAdapter() { 959 return PhoneFavoritesTileAdapter.this; 960 } 961 962 public int getPosition() { 963 return mPosition; 964 } 965 966 /** 967 * Find the view under the pointer. 968 */ 969 public View getViewAtPosition(int x, int y) { 970 // find the view under the pointer, accounting for GONE views 971 final int count = getChildCount(); 972 View view; 973 for (int childIdx = 0; childIdx < count; childIdx++) { 974 view = getChildAt(childIdx); 975 if (x >= view.getLeft() && x <= view.getRight()) { 976 return view; 977 } 978 } 979 return null; 980 } 981 982 @Override 983 public View getChildAtPosition(MotionEvent ev) { 984 final View view = getViewAtPosition((int) ev.getX(), (int) ev.getY()); 985 if (view != null && 986 SwipeHelper.isSwipeable(view) && 987 view.getVisibility() != GONE) { 988 // If this view is swipable, then return it. If not, because the removal 989 // dialog is currently showing, then return a null view, which will simply 990 // be ignored by the swipe helper. 991 return view; 992 } 993 return null; 994 } 995 996 @Override 997 public View getChildContentView(View v) { 998 return v.findViewById(R.id.contact_favorite_card); 999 } 1000 1001 @Override 1002 public void onScroll() {} 1003 1004 @Override 1005 public boolean canChildBeDismissed(View v) { 1006 return true; 1007 } 1008 1009 @Override 1010 public void onBeginDrag(View v) { 1011 removePendingContactEntry(); 1012 final int index = indexOfChild(v); 1013 1014 /* 1015 if (index > 0) { 1016 detachViewFromParent(index); 1017 attachViewToParent(v, 0, v.getLayoutParams()); 1018 }*/ 1019 1020 // We do this so the underlying ScrollView knows that it won't get 1021 // the chance to intercept events anymore 1022 requestDisallowInterceptTouchEvent(true); 1023 } 1024 1025 @Override 1026 public void onChildDismissed(View v) { 1027 if (v != null) { 1028 if (mOnItemSwipeListener != null) { 1029 mOnItemSwipeListener.onSwipe(v); 1030 } 1031 } 1032 } 1033 1034 @Override 1035 public void onDragCancelled(View v) {} 1036 1037 @Override 1038 public boolean onInterceptTouchEvent(MotionEvent ev) { 1039 if (mSwipeHelper != null && isSwipeEnabled()) { 1040 return mSwipeHelper.onInterceptTouchEvent(ev) || super.onInterceptTouchEvent(ev); 1041 } else { 1042 return super.onInterceptTouchEvent(ev); 1043 } 1044 } 1045 1046 @Override 1047 public boolean onTouchEvent(MotionEvent ev) { 1048 if (mSwipeHelper != null && isSwipeEnabled()) { 1049 return mSwipeHelper.onTouchEvent(ev) || super.onTouchEvent(ev); 1050 } else { 1051 return super.onTouchEvent(ev); 1052 } 1053 } 1054 1055 public int getItemViewType() { 1056 return mItemViewType; 1057 } 1058 1059 public void setOnItemSwipeListener(OnItemGestureListener listener) { 1060 mOnItemSwipeListener = listener; 1061 } 1062 } 1063 1064 /** 1065 * Used when a contact is swiped away. This will both unstar and set pinned position of the 1066 * contact to PinnedPosition.DEMOTED so that it doesn't show up anymore in the favorites list. 1067 */ 1068 private void unstarAndUnpinContact(Uri contactUri) { 1069 final ContentValues values = new ContentValues(2); 1070 values.put(Contacts.STARRED, false); 1071 values.put(Contacts.PINNED, PinnedPositions.DEMOTED); 1072 mContext.getContentResolver().update(contactUri, values, null, null); 1073 } 1074 1075 /** 1076 * Given a list of contacts that each have pinned positions, rearrange the list (destructive) 1077 * such that all pinned contacts are in their defined pinned positions, and unpinned contacts 1078 * take the spaces between those pinned contacts. Demoted contacts should not appear in the 1079 * resulting list. 1080 * 1081 * This method also updates the pinned positions of pinned contacts so that they are all 1082 * unique positive integers within range from 0 to toArrange.size() - 1. This is because 1083 * when the contact entries are read from the database, it is possible for them to have 1084 * overlapping pin positions due to sync or modifications by third party apps. 1085 */ 1086 @VisibleForTesting 1087 /* package */ void arrangeContactsByPinnedPosition(ArrayList<ContactEntry> toArrange) { 1088 final PriorityQueue<ContactEntry> pinnedQueue = 1089 new PriorityQueue<ContactEntry>(PIN_LIMIT, mContactEntryComparator); 1090 1091 final List<ContactEntry> unpinnedContacts = new LinkedList<ContactEntry>(); 1092 1093 final int length = toArrange.size(); 1094 for (int i = 0; i < length; i++) { 1095 final ContactEntry contact = toArrange.get(i); 1096 // Decide whether the contact is hidden(demoted), pinned, or unpinned 1097 if (contact.pinned > PIN_LIMIT) { 1098 unpinnedContacts.add(contact); 1099 } else if (contact.pinned > PinnedPositions.DEMOTED) { 1100 // Demoted or contacts with negative pinned positions are ignored. 1101 // Pinned contacts go into a priority queue where they are ranked by pinned 1102 // position. This is required because the contacts provider does not return 1103 // contacts ordered by pinned position. 1104 pinnedQueue.add(contact); 1105 } 1106 } 1107 1108 final int maxToPin = Math.min(PIN_LIMIT, pinnedQueue.size() + unpinnedContacts.size()); 1109 1110 toArrange.clear(); 1111 for (int i = 0; i < maxToPin; i++) { 1112 if (!pinnedQueue.isEmpty() && pinnedQueue.peek().pinned <= i) { 1113 final ContactEntry toPin = pinnedQueue.poll(); 1114 toPin.pinned = i; 1115 toArrange.add(toPin); 1116 } else if (!unpinnedContacts.isEmpty()) { 1117 toArrange.add(unpinnedContacts.remove(0)); 1118 } 1119 } 1120 1121 // If there are still contacts in pinnedContacts at this point, it means that the pinned 1122 // positions of these pinned contacts exceed the actual number of contacts in the list. 1123 // For example, the user had 10 frequents, starred and pinned one of them at the last spot, 1124 // and then cleared frequents. Contacts in this situation should become unpinned. 1125 while (!pinnedQueue.isEmpty()) { 1126 final ContactEntry entry = pinnedQueue.poll(); 1127 entry.pinned = PinnedPositions.UNPINNED; 1128 toArrange.add(entry); 1129 } 1130 1131 // Any remaining unpinned contacts that weren't in the gaps between the pinned contacts 1132 // now just get appended to the end of the list. 1133 toArrange.addAll(unpinnedContacts); 1134 } 1135 1136 /** 1137 * Given an existing list of contact entries and a single entry that is to be pinned at a 1138 * particular position, return a ContentValues object that contains new pinned positions for 1139 * all contacts that are forced to be pinned at new positions, trying as much as possible to 1140 * keep pinned contacts at their original location. 1141 * 1142 * At this point in time the pinned position of each contact in the list has already been 1143 * updated by {@link #arrangeContactsByPinnedPosition}, so we can assume that all pinned 1144 * positions(within {@link #PIN_LIMIT} are unique positive integers. 1145 */ 1146 @VisibleForTesting 1147 /* package */ ContentValues getReflowedPinnedPositions(ArrayList<ContactEntry> list, 1148 ContactEntry entryToPin, int oldPos, int newPinPos) { 1149 1150 final ContentValues cv = new ContentValues(); 1151 final int lowerBound = Math.min(oldPos, newPinPos); 1152 final int upperBound = Math.max(oldPos, newPinPos); 1153 for (int i = lowerBound; i <= upperBound; i++) { 1154 final ContactEntry entry = list.get(i); 1155 if (entry.pinned == i) continue; 1156 cv.put(String.valueOf(entry.id), i); 1157 } 1158 return cv; 1159 } 1160 1161 protected static class ViewTypes { 1162 public static final int FREQUENT = 0; 1163 public static final int TOP = 1; 1164 public static final int COUNT = 2; 1165 } 1166 1167 @Override 1168 public void onSwipe(View view) { 1169 final PhoneFavoriteTileView tileView = (PhoneFavoriteTileView) view.findViewById( 1170 R.id.contact_tile); 1171 // When the view is in the removal dialog, it should no longer be swipeable 1172 SwipeHelper.setSwipeable(view, false); 1173 tileView.displayRemovalDialog(); 1174 1175 final Integer entryIndex = (Integer) tileView.getTag( 1176 ContactTileRow.CONTACT_ENTRY_INDEX_TAG); 1177 1178 setPotentialRemoveEntryIndex(entryIndex); 1179 } 1180 1181 @Override 1182 public void onTouch() { 1183 removePendingContactEntry(); 1184 return; 1185 } 1186 1187 @Override 1188 public boolean isSwipeEnabled() { 1189 return !mAwaitingRemove; 1190 } 1191 1192 @Override 1193 public void onDragStarted(int itemIndex) { 1194 setInDragging(true); 1195 popContactEntry(itemIndex); 1196 } 1197 1198 @Override 1199 public void onDragHovered(int itemIndex) { 1200 if (mInDragging && 1201 mDragEnteredEntryIndex != itemIndex && 1202 isIndexInBound(itemIndex) && 1203 itemIndex < PIN_LIMIT) { 1204 markDropArea(itemIndex); 1205 } 1206 } 1207 1208 @Override 1209 public void onDragFinished() { 1210 setInDragging(false); 1211 handleDrop(); 1212 } 1213 } 1214