diff options
27 files changed, 5068 insertions, 0 deletions
@@ -23,6 +23,7 @@ LOCAL_RESOURCE_DIR := $(addprefix $(LOCAL_PATH)/, res) LOCAL_STATIC_JAVA_LIBRARIES := \ com.android.phone.shared \ guava \ + android-common \ android-support-v13 \ android-support-v4 \ diff --git a/res/drawable-hdpi/list_section_divider_holo_custom.9.png b/res/drawable-hdpi/list_section_divider_holo_custom.9.png Binary files differnew file mode 100644 index 00000000..1e3e7789 --- /dev/null +++ b/res/drawable-hdpi/list_section_divider_holo_custom.9.png diff --git a/res/drawable-mdpi/list_section_divider_holo_custom.9.png b/res/drawable-mdpi/list_section_divider_holo_custom.9.png Binary files differnew file mode 100644 index 00000000..1d8fd090 --- /dev/null +++ b/res/drawable-mdpi/list_section_divider_holo_custom.9.png diff --git a/res/drawable-xhdpi/list_section_divider_holo_custom.9.png b/res/drawable-xhdpi/list_section_divider_holo_custom.9.png Binary files differnew file mode 100644 index 00000000..0bd8a0f2 --- /dev/null +++ b/res/drawable-xhdpi/list_section_divider_holo_custom.9.png diff --git a/res/layout/directory_header.xml b/res/layout/directory_header.xml new file mode 100644 index 00000000..fcd255ef --- /dev/null +++ b/res/layout/directory_header.xml @@ -0,0 +1,67 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2009 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<!-- Layout used for list section separators. --> +<FrameLayout + xmlns:android="http://schemas.android.com/apk/res/android" + style="@style/DirectoryHeader" + android:id="@+id/directory_header" + android:paddingLeft="?attr/list_item_padding_left" + android:paddingRight="?attr/list_item_padding_right" + android:minHeight="@dimen/list_section_divider_min_height" + android:layout_marginTop="@dimen/list_header_extra_top_padding" + android:layout_width="match_parent" + android:layout_height="wrap_content" + > + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:gravity="center_vertical" + android:background="@drawable/list_section_divider_holo_custom" + > + <TextView + android:id="@+id/label" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginLeft="8dip" + android:textAppearance="?android:attr/textAppearanceSmall" + android:textColor="@color/people_app_theme_color" + android:singleLine="true" + android:textStyle="bold" + android:textAllCaps="true" /> + <TextView + android:id="@+id/display_name" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginLeft="8dip" + android:layout_marginRight="8dip" + android:textAppearance="?android:attr/textAppearanceSmall" + android:textColor="@color/people_app_theme_color" + android:singleLine="true" + android:textStyle="bold" + android:textAllCaps="true" /> + <TextView + android:id="@+id/count" + android:paddingTop="1dip" + android:layout_width="0dip" + android:layout_height="wrap_content" + android:layout_weight="1" + android:gravity="right" + android:singleLine="true" + android:textSize="12sp" + android:textColor="@color/contact_count_text_color" /> + </LinearLayout> +</FrameLayout> diff --git a/res/values/attrs.xml b/res/values/attrs.xml new file mode 100644 index 00000000..eae9c36f --- /dev/null +++ b/res/values/attrs.xml @@ -0,0 +1,48 @@ +<!-- + ~ Copyright (C) 2012 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License + --> + +<resources> + + <declare-styleable name="ContactListItemView"> + <attr name="list_item_height" format="dimension"/> + <attr name="list_section_header_height" format="dimension"/> + <attr name="activated_background" format="reference"/> + <attr name="section_header_background" format="reference"/> + <attr name="list_item_divider" format="reference"/> + <attr name="list_item_padding_top" format="dimension"/> + <attr name="list_item_padding_right" format="dimension"/> + <attr name="list_item_padding_bottom" format="dimension"/> + <attr name="list_item_padding_left" format="dimension"/> + <attr name="list_item_gap_between_image_and_text" format="dimension"/> + <attr name="list_item_gap_between_label_and_data" format="dimension"/> + <attr name="list_item_presence_icon_margin" format="dimension"/> + <attr name="list_item_presence_icon_size" format="dimension"/> + <attr name="list_item_photo_size" format="dimension"/> + <attr name="list_item_profile_photo_size" format="dimension"/> + <attr name="list_item_prefix_highlight_color" format="color"/> + <attr name="list_item_header_text_indent" format="dimension"/> + <attr name="list_item_header_text_color" format="color"/> + <attr name="list_item_header_text_size" format="dimension"/> + <attr name="list_item_header_height" format="dimension"/> + <attr name="list_item_header_underline_height" format="dimension"/> + <attr name="list_item_header_underline_color" format="color"/> + <attr name="list_item_contacts_count_text_color" format="color"/> + <attr name="list_item_text_indent" format="dimension"/> + <attr name="list_item_contacts_count_text_size" format="dimension"/> + <attr name="list_item_data_width_weight" format="integer"/> + <attr name="list_item_label_width_weight" format="integer"/> + </declare-styleable> +</resources> diff --git a/res/values/colors.xml b/res/values/colors.xml index 5f9044a3..13eadd35 100644 --- a/res/values/colors.xml +++ b/res/values/colors.xml @@ -16,6 +16,12 @@ <resources> + <!-- Colors in the contact browser list --> + <color name="contact_count_text_color">#AAAAAA</color> + + <!-- Color of the theme of the People app --> + <color name="people_app_theme_color">#33B5E5</color> + <!-- Color of image view placeholder. --> <color name="image_placeholder">#DDDDDD</color> diff --git a/res/values/dimens.xml b/res/values/dimens.xml new file mode 100644 index 00000000..c17234c5 --- /dev/null +++ b/res/values/dimens.xml @@ -0,0 +1,27 @@ +<!-- + ~ Copyright (C) 2012 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License + --> + +<resources> + + <!-- Padding between the action bar's bottom edge and the first header + in contacts/group lists. --> + <dimen name="list_header_extra_top_padding">0dip</dimen> + + <!-- Minimum height used with @drawable/list_section_divider_holo_custom. + Right now the drawable has implicit 32dip minimal height, which is confusing. + This value is for making the hidden configuration explicit in xml. --> + <dimen name="list_section_divider_min_height">32dip</dimen> +</resources> diff --git a/res/values/donottranslate_config.xml b/res/values/donottranslate_config.xml new file mode 100644 index 00000000..8603bb71 --- /dev/null +++ b/res/values/donottranslate_config.xml @@ -0,0 +1,30 @@ +<!-- + ~ Copyright (C) 2012 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License + --> + +<resources> + + <!-- If true, an option is shown in Display Options UI to choose a sort order --> + <bool name="config_sort_order_user_changeable">true</bool> + + <!-- If true, the default sort order is primary (i.e. by given name) --> + <bool name="config_default_sort_order_primary">true</bool> + + <!-- If true, an option is shown in Display Options UI to choose a name display order --> + <bool name="config_display_order_user_changeable">true</bool> + + <!-- If true, the default sort order is primary (i.e. by given name) --> + <bool name="config_default_display_order_primary">true</bool> +</resources> diff --git a/res/values/strings.xml b/res/values/strings.xml index 7e288479..87ea719c 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -135,4 +135,46 @@ <!-- Used to display as default status when the contact is busy or Do not disturb for chat [CHAR LIMIT=19] --> <string name="status_busy">Busy</string> + <!-- Directory partition name (also exists in contacts) --> + <string name="contactsList">Contacts</string> + + <!-- The name of the invisible local contact directory --> + <string name="local_invisible_directory">Other</string> + + <!-- The label in section header in the contact list for a contact directory [CHAR LIMIT=128] --> + <string name="directory_search_label">Directory</string> + + <!-- The label in section header in the contact list for a local contacts [CHAR LIMIT=128] --> + <string name="local_search_label">All contacts</string> + + <!-- String describing the text on the header of the profile contact in the contacts list + This may be programatically capitalized. [CHAR LIMIT=20] --> + <string name="user_profile_contacts_list_header" msgid="9154761216179882405">Me</string> + + <!-- Title shown in the search result activity of contacts app while searching. [CHAR LIMIT=20] + (also in contacts) --> + <string name="search_results_searching">Searching\u2026</string> + + <!-- Displayed at the top of search results indicating that more contacts were found than shown [CHAR LIMIT=64] --> + <string name="foundTooManyContacts">More than <xliff:g id="count">%d</xliff:g> found.</string> + + <!-- Displayed at the top of the contacts showing the zero total number of contacts found when "Only contacts with phones" not selected. [CHAR LIMIT=30] + (also in contacts) --> + <string name="listFoundAllContactsZero">No contacts</string> + + <!-- Displayed at the top of the contacts showing the total number of contacts found when typing search query --> + <plurals name="searchFoundContacts"> + <item quantity="one">1 found</item> + <item quantity="other"><xliff:g id="count">%d</xliff:g> found</item> + </plurals> + + <!-- String describing the text for photo of a contact in a contacts list. + + Note: AccessibilityServices use this attribute to announce what the view represents. + This is especially valuable for views without textual representation like ImageView. + --> + <string name="description_quick_contact_for">Quick contact for <xliff:g id="name">%1$s</xliff:g></string> + + <!-- Shown as the display name for a person when the name is missing or unknown. [CHAR LIMIT=18]--> + <string name="missing_name">(No name)</string> </resources> diff --git a/res/values/styles.xml b/res/values/styles.xml new file mode 100644 index 00000000..45137e73 --- /dev/null +++ b/res/values/styles.xml @@ -0,0 +1,22 @@ +<!-- + ~ Copyright (C) 2012 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License + --> + +<resources> + + <style name="DirectoryHeader"> + <item name="android:background">@android:color/transparent</item> + </style> +</resources> diff --git a/src/com/android/contacts/common/list/AutoScrollListView.java b/src/com/android/contacts/common/list/AutoScrollListView.java new file mode 100644 index 00000000..ae7ca171 --- /dev/null +++ b/src/com/android/contacts/common/list/AutoScrollListView.java @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.list; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.ListView; + +/** + * A ListView that can be asked to scroll (smoothly or otherwise) to a specific + * position. This class takes advantage of similar functionality that exists + * in {@link ListView} and enhances it. + */ +public class AutoScrollListView extends ListView { + + /** + * Position the element at about 1/3 of the list height + */ + private static final float PREFERRED_SELECTION_OFFSET_FROM_TOP = 0.33f; + + private int mRequestedScrollPosition = -1; + private boolean mSmoothScrollRequested; + + public AutoScrollListView(Context context) { + super(context); + } + + public AutoScrollListView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public AutoScrollListView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + /** + * Brings the specified position to view by optionally performing a jump-scroll maneuver: + * first it jumps to some position near the one requested and then does a smooth + * scroll to the requested position. This creates an impression of full smooth + * scrolling without actually traversing the entire list. If smooth scrolling is + * not requested, instantly positions the requested item at a preferred offset. + */ + public void requestPositionToScreen(int position, boolean smoothScroll) { + mRequestedScrollPosition = position; + mSmoothScrollRequested = smoothScroll; + requestLayout(); + } + + @Override + protected void layoutChildren() { + super.layoutChildren(); + if (mRequestedScrollPosition == -1) { + return; + } + + final int position = mRequestedScrollPosition; + mRequestedScrollPosition = -1; + + int firstPosition = getFirstVisiblePosition() + 1; + int lastPosition = getLastVisiblePosition(); + if (position >= firstPosition && position <= lastPosition) { + return; // Already on screen + } + + final int offset = (int) (getHeight() * PREFERRED_SELECTION_OFFSET_FROM_TOP); + if (!mSmoothScrollRequested) { + setSelectionFromTop(position, offset); + + // Since we have changed the scrolling position, we need to redo child layout + // Calling "requestLayout" in the middle of a layout pass has no effect, + // so we call layoutChildren explicitly + super.layoutChildren(); + + } else { + // We will first position the list a couple of screens before or after + // the new selection and then scroll smoothly to it. + int twoScreens = (lastPosition - firstPosition) * 2; + int preliminaryPosition; + if (position < firstPosition) { + preliminaryPosition = position + twoScreens; + if (preliminaryPosition >= getCount()) { + preliminaryPosition = getCount() - 1; + } + if (preliminaryPosition < firstPosition) { + setSelection(preliminaryPosition); + super.layoutChildren(); + } + } else { + preliminaryPosition = position - twoScreens; + if (preliminaryPosition < 0) { + preliminaryPosition = 0; + } + if (preliminaryPosition > lastPosition) { + setSelection(preliminaryPosition); + super.layoutChildren(); + } + } + + + smoothScrollToPositionFromTop(position, offset); + } + } +} diff --git a/src/com/android/contacts/common/list/ContactEntryListAdapter.java b/src/com/android/contacts/common/list/ContactEntryListAdapter.java new file mode 100644 index 00000000..3653796e --- /dev/null +++ b/src/com/android/contacts/common/list/ContactEntryListAdapter.java @@ -0,0 +1,672 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.contacts.common.list; + +import android.content.Context; +import android.content.CursorLoader; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.provider.ContactsContract; +import android.provider.ContactsContract.ContactCounts; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.Directory; +import android.text.TextUtils; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.QuickContactBadge; +import android.widget.SectionIndexer; +import android.widget.TextView; + +import com.android.contacts.common.ContactPhotoManager; +import com.android.contacts.common.R; + +import java.util.HashSet; + +/** + * Common base class for various contact-related lists, e.g. contact list, phone number list + * etc. + */ +public abstract class ContactEntryListAdapter extends IndexerListAdapter { + + private static final String TAG = "ContactEntryListAdapter"; + + /** + * Indicates whether the {@link Directory#LOCAL_INVISIBLE} directory should + * be included in the search. + */ + public static final boolean LOCAL_INVISIBLE_DIRECTORY_ENABLED = false; + + private int mDisplayOrder; + private int mSortOrder; + + private boolean mDisplayPhotos; + private boolean mQuickContactEnabled; + + /** + * indicates if contact queries include profile + */ + private boolean mIncludeProfile; + + /** + * indicates if query results includes a profile + */ + private boolean mProfileExists; + + private ContactPhotoManager mPhotoLoader; + + private String mQueryString; + private char[] mUpperCaseQueryString; + private boolean mSearchMode; + private int mDirectorySearchMode; + private int mDirectoryResultLimit = Integer.MAX_VALUE; + + private boolean mLoading = true; + private boolean mEmptyListEnabled = true; + + private boolean mSelectionVisible; + + private ContactListFilter mFilter; + private String mContactsCount = ""; + private boolean mDarkTheme = false; + + /** Resource used to provide header-text for default filter. */ + private CharSequence mDefaultFilterHeaderText; + + public ContactEntryListAdapter(Context context) { + super(context); + addPartitions(); + setDefaultFilterHeaderText(R.string.local_search_label); + } + + protected void setDefaultFilterHeaderText(int resourceId) { + mDefaultFilterHeaderText = getContext().getResources().getText(resourceId); + } + + @Override + protected View createPinnedSectionHeaderView(Context context, ViewGroup parent) { + return new ContactListPinnedHeaderView(context, null); + } + + @Override + protected void setPinnedSectionTitle(View pinnedHeaderView, String title) { + ((ContactListPinnedHeaderView)pinnedHeaderView).setSectionHeader(title); + } + + @Override + protected void setPinnedHeaderContactsCount(View header) { + // Update the header with the contacts count only if a profile header exists + // otherwise, the contacts count are shown in the empty profile header view + if (mProfileExists) { + ((ContactListPinnedHeaderView)header).setCountView(mContactsCount); + } else { + clearPinnedHeaderContactsCount(header); + } + } + + @Override + protected void clearPinnedHeaderContactsCount(View header) { + ((ContactListPinnedHeaderView)header).setCountView(null); + } + + protected void addPartitions() { + addPartition(createDefaultDirectoryPartition()); + } + + protected DirectoryPartition createDefaultDirectoryPartition() { + DirectoryPartition partition = new DirectoryPartition(true, true); + partition.setDirectoryId(Directory.DEFAULT); + partition.setDirectoryType(getContext().getString(R.string.contactsList)); + partition.setPriorityDirectory(true); + partition.setPhotoSupported(true); + return partition; + } + + /** + * Remove all directories after the default directory. This is typically used when contacts + * list screens are asked to exit the search mode and thus need to remove all remote directory + * results for the search. + * + * This code assumes that the default directory and directories before that should not be + * deleted (e.g. Join screen has "suggested contacts" directory before the default director, + * and we should not remove the directory). + */ + public void removeDirectoriesAfterDefault() { + final int partitionCount = getPartitionCount(); + for (int i = partitionCount - 1; i >= 0; i--) { + final Partition partition = getPartition(i); + if ((partition instanceof DirectoryPartition) + && ((DirectoryPartition) partition).getDirectoryId() == Directory.DEFAULT) { + break; + } else { + removePartition(i); + } + } + } + + private int getPartitionByDirectoryId(long id) { + int count = getPartitionCount(); + for (int i = 0; i < count; i++) { + Partition partition = getPartition(i); + if (partition instanceof DirectoryPartition) { + if (((DirectoryPartition)partition).getDirectoryId() == id) { + return i; + } + } + } + return -1; + } + + public abstract String getContactDisplayName(int position); + public abstract void configureLoader(CursorLoader loader, long directoryId); + + /** + * Marks all partitions as "loading" + */ + public void onDataReload() { + boolean notify = false; + int count = getPartitionCount(); + for (int i = 0; i < count; i++) { + Partition partition = getPartition(i); + if (partition instanceof DirectoryPartition) { + DirectoryPartition directoryPartition = (DirectoryPartition)partition; + if (!directoryPartition.isLoading()) { + notify = true; + } + directoryPartition.setStatus(DirectoryPartition.STATUS_NOT_LOADED); + } + } + if (notify) { + notifyDataSetChanged(); + } + } + + @Override + public void clearPartitions() { + int count = getPartitionCount(); + for (int i = 0; i < count; i++) { + Partition partition = getPartition(i); + if (partition instanceof DirectoryPartition) { + DirectoryPartition directoryPartition = (DirectoryPartition)partition; + directoryPartition.setStatus(DirectoryPartition.STATUS_NOT_LOADED); + } + } + super.clearPartitions(); + } + + public boolean isSearchMode() { + return mSearchMode; + } + + public void setSearchMode(boolean flag) { + mSearchMode = flag; + } + + public String getQueryString() { + return mQueryString; + } + + public void setQueryString(String queryString) { + mQueryString = queryString; + if (TextUtils.isEmpty(queryString)) { + mUpperCaseQueryString = null; + } else { + mUpperCaseQueryString = queryString.toUpperCase().toCharArray(); + } + } + + public char[] getUpperCaseQueryString() { + return mUpperCaseQueryString; + } + + public int getDirectorySearchMode() { + return mDirectorySearchMode; + } + + public void setDirectorySearchMode(int mode) { + mDirectorySearchMode = mode; + } + + public int getDirectoryResultLimit() { + return mDirectoryResultLimit; + } + + public void setDirectoryResultLimit(int limit) { + this.mDirectoryResultLimit = limit; + } + + public int getContactNameDisplayOrder() { + return mDisplayOrder; + } + + public void setContactNameDisplayOrder(int displayOrder) { + mDisplayOrder = displayOrder; + } + + public int getSortOrder() { + return mSortOrder; + } + + public void setSortOrder(int sortOrder) { + mSortOrder = sortOrder; + } + + public void setPhotoLoader(ContactPhotoManager photoLoader) { + mPhotoLoader = photoLoader; + } + + protected ContactPhotoManager getPhotoLoader() { + return mPhotoLoader; + } + + public boolean getDisplayPhotos() { + return mDisplayPhotos; + } + + public void setDisplayPhotos(boolean displayPhotos) { + mDisplayPhotos = displayPhotos; + } + + public boolean isEmptyListEnabled() { + return mEmptyListEnabled; + } + + public void setEmptyListEnabled(boolean flag) { + mEmptyListEnabled = flag; + } + + public boolean isSelectionVisible() { + return mSelectionVisible; + } + + public void setSelectionVisible(boolean flag) { + this.mSelectionVisible = flag; + } + + public boolean isQuickContactEnabled() { + return mQuickContactEnabled; + } + + public void setQuickContactEnabled(boolean quickContactEnabled) { + mQuickContactEnabled = quickContactEnabled; + } + + public boolean shouldIncludeProfile() { + return mIncludeProfile; + } + + public void setIncludeProfile(boolean includeProfile) { + mIncludeProfile = includeProfile; + } + + public void setProfileExists(boolean exists) { + mProfileExists = exists; + // Stick the "ME" header for the profile + if (exists) { + SectionIndexer indexer = getIndexer(); + if (indexer != null) { + ((ContactsSectionIndexer) indexer).setProfileHeader( + getContext().getString(R.string.user_profile_contacts_list_header)); + } + } + } + + public boolean hasProfile() { + return mProfileExists; + } + + public void setDarkTheme(boolean value) { + mDarkTheme = value; + } + + /** + * Updates partitions according to the directory meta-data contained in the supplied + * cursor. + */ + public void changeDirectories(Cursor cursor) { + if (cursor.getCount() == 0) { + // Directory table must have at least local directory, without which this adapter will + // enter very weird state. + Log.e(TAG, "Directory search loader returned an empty cursor, which implies we have " + + "no directory entries.", new RuntimeException()); + return; + } + HashSet<Long> directoryIds = new HashSet<Long>(); + + int idColumnIndex = cursor.getColumnIndex(Directory._ID); + int directoryTypeColumnIndex = cursor.getColumnIndex(DirectoryListLoader.DIRECTORY_TYPE); + int displayNameColumnIndex = cursor.getColumnIndex(Directory.DISPLAY_NAME); + int photoSupportColumnIndex = cursor.getColumnIndex(Directory.PHOTO_SUPPORT); + + // TODO preserve the order of partition to match those of the cursor + // Phase I: add new directories + cursor.moveToPosition(-1); + while (cursor.moveToNext()) { + long id = cursor.getLong(idColumnIndex); + directoryIds.add(id); + if (getPartitionByDirectoryId(id) == -1) { + DirectoryPartition partition = new DirectoryPartition(false, true); + partition.setDirectoryId(id); + partition.setDirectoryType(cursor.getString(directoryTypeColumnIndex)); + partition.setDisplayName(cursor.getString(displayNameColumnIndex)); + int photoSupport = cursor.getInt(photoSupportColumnIndex); + partition.setPhotoSupported(photoSupport == Directory.PHOTO_SUPPORT_THUMBNAIL_ONLY + || photoSupport == Directory.PHOTO_SUPPORT_FULL); + addPartition(partition); + } + } + + // Phase II: remove deleted directories + int count = getPartitionCount(); + for (int i = count; --i >= 0; ) { + Partition partition = getPartition(i); + if (partition instanceof DirectoryPartition) { + long id = ((DirectoryPartition)partition).getDirectoryId(); + if (!directoryIds.contains(id)) { + removePartition(i); + } + } + } + + invalidate(); + notifyDataSetChanged(); + } + + @Override + public void changeCursor(int partitionIndex, Cursor cursor) { + if (partitionIndex >= getPartitionCount()) { + // There is no partition for this data + return; + } + + Partition partition = getPartition(partitionIndex); + if (partition instanceof DirectoryPartition) { + ((DirectoryPartition)partition).setStatus(DirectoryPartition.STATUS_LOADED); + } + + if (mDisplayPhotos && mPhotoLoader != null && isPhotoSupported(partitionIndex)) { + mPhotoLoader.refreshCache(); + } + + super.changeCursor(partitionIndex, cursor); + + if (isSectionHeaderDisplayEnabled() && partitionIndex == getIndexedPartition()) { + updateIndexer(cursor); + } + } + + public void changeCursor(Cursor cursor) { + changeCursor(0, cursor); + } + + /** + * Updates the indexer, which is used to produce section headers. + */ + private void updateIndexer(Cursor cursor) { + if (cursor == null) { + setIndexer(null); + return; + } + + Bundle bundle = cursor.getExtras(); + if (bundle.containsKey(ContactCounts.EXTRA_ADDRESS_BOOK_INDEX_TITLES)) { + String sections[] = + bundle.getStringArray(ContactCounts.EXTRA_ADDRESS_BOOK_INDEX_TITLES); + int counts[] = bundle.getIntArray(ContactCounts.EXTRA_ADDRESS_BOOK_INDEX_COUNTS); + setIndexer(new ContactsSectionIndexer(sections, counts)); + } else { + setIndexer(null); + } + } + + @Override + public int getViewTypeCount() { + // We need a separate view type for each item type, plus another one for + // each type with header, plus one for "other". + return getItemViewTypeCount() * 2 + 1; + } + + @Override + public int getItemViewType(int partitionIndex, int position) { + int type = super.getItemViewType(partitionIndex, position); + if (!isUserProfile(position) + && isSectionHeaderDisplayEnabled() + && partitionIndex == getIndexedPartition()) { + Placement placement = getItemPlacementInSection(position); + return placement.firstInSection ? type : getItemViewTypeCount() + type; + } else { + return type; + } + } + + @Override + public boolean isEmpty() { + // TODO +// if (contactsListActivity.mProviderStatus != ProviderStatus.STATUS_NORMAL) { +// return true; +// } + + if (!mEmptyListEnabled) { + return false; + } else if (isSearchMode()) { + return TextUtils.isEmpty(getQueryString()); + } else if (mLoading) { + // We don't want the empty state to show when loading. + return false; + } else { + return super.isEmpty(); + } + } + + public boolean isLoading() { + int count = getPartitionCount(); + for (int i = 0; i < count; i++) { + Partition partition = getPartition(i); + if (partition instanceof DirectoryPartition + && ((DirectoryPartition) partition).isLoading()) { + return true; + } + } + return false; + } + + public boolean areAllPartitionsEmpty() { + int count = getPartitionCount(); + for (int i = 0; i < count; i++) { + if (!isPartitionEmpty(i)) { + return false; + } + } + return true; + } + + /** + * Changes visibility parameters for the default directory partition. + */ + public void configureDefaultPartition(boolean showIfEmpty, boolean hasHeader) { + int defaultPartitionIndex = -1; + int count = getPartitionCount(); + for (int i = 0; i < count; i++) { + Partition partition = getPartition(i); + if (partition instanceof DirectoryPartition && + ((DirectoryPartition)partition).getDirectoryId() == Directory.DEFAULT) { + defaultPartitionIndex = i; + break; + } + } + if (defaultPartitionIndex != -1) { + setShowIfEmpty(defaultPartitionIndex, showIfEmpty); + setHasHeader(defaultPartitionIndex, hasHeader); + } + } + + @Override + protected View newHeaderView(Context context, int partition, Cursor cursor, + ViewGroup parent) { + LayoutInflater inflater = LayoutInflater.from(context); + return inflater.inflate(R.layout.directory_header, parent, false); + } + + @Override + protected void bindHeaderView(View view, int partitionIndex, Cursor cursor) { + Partition partition = getPartition(partitionIndex); + if (!(partition instanceof DirectoryPartition)) { + return; + } + + DirectoryPartition directoryPartition = (DirectoryPartition)partition; + long directoryId = directoryPartition.getDirectoryId(); + TextView labelTextView = (TextView)view.findViewById(R.id.label); + TextView displayNameTextView = (TextView)view.findViewById(R.id.display_name); + if (directoryId == Directory.DEFAULT || directoryId == Directory.LOCAL_INVISIBLE) { + labelTextView.setText(mDefaultFilterHeaderText); + displayNameTextView.setText(null); + } else { + labelTextView.setText(R.string.directory_search_label); + String directoryName = directoryPartition.getDisplayName(); + String displayName = !TextUtils.isEmpty(directoryName) + ? directoryName + : directoryPartition.getDirectoryType(); + displayNameTextView.setText(displayName); + } + + TextView countText = (TextView)view.findViewById(R.id.count); + if (directoryPartition.isLoading()) { + countText.setText(R.string.search_results_searching); + } else { + int count = cursor == null ? 0 : cursor.getCount(); + if (directoryId != Directory.DEFAULT && directoryId != Directory.LOCAL_INVISIBLE + && count >= getDirectoryResultLimit()) { + countText.setText(mContext.getString( + R.string.foundTooManyContacts, getDirectoryResultLimit())); + } else { + countText.setText(getQuantityText( + count, R.string.listFoundAllContactsZero, R.plurals.searchFoundContacts)); + } + } + } + + /** + * Checks whether the contact entry at the given position represents the user's profile. + */ + protected boolean isUserProfile(int position) { + // The profile only ever appears in the first position if it is present. So if the position + // is anything beyond 0, it can't be the profile. + boolean isUserProfile = false; + if (position == 0) { + int partition = getPartitionForPosition(position); + if (partition >= 0) { + // Save the old cursor position - the call to getItem() may modify the cursor + // position. + int offset = getCursor(partition).getPosition(); + Cursor cursor = (Cursor) getItem(position); + if (cursor != null) { + int profileColumnIndex = cursor.getColumnIndex(Contacts.IS_USER_PROFILE); + if (profileColumnIndex != -1) { + isUserProfile = cursor.getInt(profileColumnIndex) == 1; + } + // Restore the old cursor position. + cursor.moveToPosition(offset); + } + } + } + return isUserProfile; + } + + // TODO: fix PluralRules to handle zero correctly and use Resources.getQuantityText directly + public String getQuantityText(int count, int zeroResourceId, int pluralResourceId) { + if (count == 0) { + return getContext().getString(zeroResourceId); + } else { + String format = getContext().getResources() + .getQuantityText(pluralResourceId, count).toString(); + return String.format(format, count); + } + } + + public boolean isPhotoSupported(int partitionIndex) { + Partition partition = getPartition(partitionIndex); + if (partition instanceof DirectoryPartition) { + return ((DirectoryPartition) partition).isPhotoSupported(); + } + return true; + } + + /** + * Returns the currently selected filter. + */ + public ContactListFilter getFilter() { + return mFilter; + } + + public void setFilter(ContactListFilter filter) { + mFilter = filter; + } + + // TODO: move sharable logic (bindXX() methods) to here with extra arguments + + /** + * Loads the photo for the quick contact view and assigns the contact uri. + * @param photoIdColumn Index of the photo id column + * @param photoUriColumn Index of the photo uri column. Optional: Can be -1 + * @param contactIdColumn Index of the contact id column + * @param lookUpKeyColumn Index of the lookup key column + */ + protected void bindQuickContact(final ContactListItemView view, int partitionIndex, + Cursor cursor, int photoIdColumn, int photoUriColumn, int contactIdColumn, + int lookUpKeyColumn) { + long photoId = 0; + if (!cursor.isNull(photoIdColumn)) { + photoId = cursor.getLong(photoIdColumn); + } + + QuickContactBadge quickContact = view.getQuickContact(); + quickContact.assignContactUri( + getContactUri(partitionIndex, cursor, contactIdColumn, lookUpKeyColumn)); + + if (photoId != 0 || photoUriColumn == -1) { + getPhotoLoader().loadThumbnail(quickContact, photoId, mDarkTheme); + } else { + final String photoUriString = cursor.getString(photoUriColumn); + final Uri photoUri = photoUriString == null ? null : Uri.parse(photoUriString); + getPhotoLoader().loadPhoto(quickContact, photoUri, -1, mDarkTheme); + } + + } + + protected Uri getContactUri(int partitionIndex, Cursor cursor, + int contactIdColumn, int lookUpKeyColumn) { + long contactId = cursor.getLong(contactIdColumn); + String lookupKey = cursor.getString(lookUpKeyColumn); + Uri uri = Contacts.getLookupUri(contactId, lookupKey); + long directoryId = ((DirectoryPartition)getPartition(partitionIndex)).getDirectoryId(); + if (directoryId != Directory.DEFAULT) { + uri = uri.buildUpon().appendQueryParameter( + ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(directoryId)).build(); + } + return uri; + } + + public void setContactsCount(String count) { + mContactsCount = count; + } + + public String getContactsCount() { + return mContactsCount; + } +} diff --git a/src/com/android/contacts/common/list/ContactListAdapter.java b/src/com/android/contacts/common/list/ContactListAdapter.java new file mode 100644 index 00000000..1be48c42 --- /dev/null +++ b/src/com/android/contacts/common/list/ContactListAdapter.java @@ -0,0 +1,362 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.contacts.common.list; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.ContactsContract; +import android.provider.ContactsContract.ContactCounts; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.Directory; +import android.provider.ContactsContract.SearchSnippetColumns; +import android.text.TextUtils; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ListView; + +import com.android.contacts.common.R; + +/** + * A cursor adapter for the {@link ContactsContract.Contacts#CONTENT_TYPE} content type. + * Also includes support for including the {@link ContactsContract.Profile} record in the + * list. + */ +public abstract class ContactListAdapter extends ContactEntryListAdapter { + + protected static class ContactQuery { + private static final String[] CONTACT_PROJECTION_PRIMARY = new String[] { + Contacts._ID, // 0 + Contacts.DISPLAY_NAME_PRIMARY, // 1 + Contacts.CONTACT_PRESENCE, // 2 + Contacts.CONTACT_STATUS, // 3 + Contacts.PHOTO_ID, // 4 + Contacts.PHOTO_THUMBNAIL_URI, // 5 + Contacts.LOOKUP_KEY, // 6 + Contacts.IS_USER_PROFILE, // 7 + }; + + private static final String[] CONTACT_PROJECTION_ALTERNATIVE = new String[] { + Contacts._ID, // 0 + Contacts.DISPLAY_NAME_ALTERNATIVE, // 1 + Contacts.CONTACT_PRESENCE, // 2 + Contacts.CONTACT_STATUS, // 3 + Contacts.PHOTO_ID, // 4 + Contacts.PHOTO_THUMBNAIL_URI, // 5 + Contacts.LOOKUP_KEY, // 6 + Contacts.IS_USER_PROFILE, // 7 + }; + + private static final String[] FILTER_PROJECTION_PRIMARY = new String[] { + Contacts._ID, // 0 + Contacts.DISPLAY_NAME_PRIMARY, // 1 + Contacts.CONTACT_PRESENCE, // 2 + Contacts.CONTACT_STATUS, // 3 + Contacts.PHOTO_ID, // 4 + Contacts.PHOTO_THUMBNAIL_URI, // 5 + Contacts.LOOKUP_KEY, // 6 + Contacts.IS_USER_PROFILE, // 7 + SearchSnippetColumns.SNIPPET, // 8 + }; + + private static final String[] FILTER_PROJECTION_ALTERNATIVE = new String[] { + Contacts._ID, // 0 + Contacts.DISPLAY_NAME_ALTERNATIVE, // 1 + Contacts.CONTACT_PRESENCE, // 2 + Contacts.CONTACT_STATUS, // 3 + Contacts.PHOTO_ID, // 4 + Contacts.PHOTO_THUMBNAIL_URI, // 5 + Contacts.LOOKUP_KEY, // 6 + Contacts.IS_USER_PROFILE, // 7 + SearchSnippetColumns.SNIPPET, // 8 + }; + + public static final int CONTACT_ID = 0; + public static final int CONTACT_DISPLAY_NAME = 1; + public static final int CONTACT_PRESENCE_STATUS = 2; + public static final int CONTACT_CONTACT_STATUS = 3; + public static final int CONTACT_PHOTO_ID = 4; + public static final int CONTACT_PHOTO_URI = 5; + public static final int CONTACT_LOOKUP_KEY = 6; + public static final int CONTACT_IS_USER_PROFILE = 7; + public static final int CONTACT_SNIPPET = 8; + } + + private CharSequence mUnknownNameText; + + private long mSelectedContactDirectoryId; + private String mSelectedContactLookupKey; + private long mSelectedContactId; + + public ContactListAdapter(Context context) { + super(context); + + mUnknownNameText = context.getText(R.string.missing_name); + } + + public CharSequence getUnknownNameText() { + return mUnknownNameText; + } + + public long getSelectedContactDirectoryId() { + return mSelectedContactDirectoryId; + } + + public String getSelectedContactLookupKey() { + return mSelectedContactLookupKey; + } + + public long getSelectedContactId() { + return mSelectedContactId; + } + + public void setSelectedContact(long selectedDirectoryId, String lookupKey, long contactId) { + mSelectedContactDirectoryId = selectedDirectoryId; + mSelectedContactLookupKey = lookupKey; + mSelectedContactId = contactId; + } + + protected static Uri buildSectionIndexerUri(Uri uri) { + return uri.buildUpon() + .appendQueryParameter(ContactCounts.ADDRESS_BOOK_INDEX_EXTRAS, "true").build(); + } + + @Override + public String getContactDisplayName(int position) { + return ((Cursor) getItem(position)).getString(ContactQuery.CONTACT_DISPLAY_NAME); + } + + /** + * Builds the {@link Contacts#CONTENT_LOOKUP_URI} for the given + * {@link ListView} position. + */ + public Uri getContactUri(int position) { + int partitionIndex = getPartitionForPosition(position); + Cursor item = (Cursor)getItem(position); + return item != null ? getContactUri(partitionIndex, item) : null; + } + + public Uri getContactUri(int partitionIndex, Cursor cursor) { + long contactId = cursor.getLong(ContactQuery.CONTACT_ID); + String lookupKey = cursor.getString(ContactQuery.CONTACT_LOOKUP_KEY); + Uri uri = Contacts.getLookupUri(contactId, lookupKey); + long directoryId = ((DirectoryPartition)getPartition(partitionIndex)).getDirectoryId(); + if (directoryId != Directory.DEFAULT) { + uri = uri.buildUpon().appendQueryParameter( + ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(directoryId)).build(); + } + return uri; + } + + /** + * Returns true if the specified contact is selected in the list. For a + * contact to be shown as selected, we need both the directory and and the + * lookup key to be the same. We are paying no attention to the contactId, + * because it is volatile, especially in the case of directories. + */ + public boolean isSelectedContact(int partitionIndex, Cursor cursor) { + long directoryId = ((DirectoryPartition)getPartition(partitionIndex)).getDirectoryId(); + if (getSelectedContactDirectoryId() != directoryId) { + return false; + } + String lookupKey = getSelectedContactLookupKey(); + if (lookupKey != null && TextUtils.equals(lookupKey, + cursor.getString(ContactQuery.CONTACT_LOOKUP_KEY))) { + return true; + } + + return directoryId != Directory.DEFAULT && directoryId != Directory.LOCAL_INVISIBLE + && getSelectedContactId() == cursor.getLong(ContactQuery.CONTACT_ID); + } + + @Override + protected View newView(Context context, int partition, Cursor cursor, int position, + ViewGroup parent) { + ContactListItemView view = new ContactListItemView(context, null); + view.setUnknownNameText(mUnknownNameText); + view.setQuickContactEnabled(isQuickContactEnabled()); + view.setActivatedStateSupported(isSelectionVisible()); + return view; + } + + protected void bindSectionHeaderAndDivider(ContactListItemView view, int position, + Cursor cursor) { + if (isSectionHeaderDisplayEnabled()) { + Placement placement = getItemPlacementInSection(position); + + // First position, set the contacts number string + if (position == 0 && cursor.getInt(ContactQuery.CONTACT_IS_USER_PROFILE) == 1) { + view.setCountView(getContactsCount()); + } else { + view.setCountView(null); + } + view.setSectionHeader(placement.sectionHeader); + view.setDividerVisible(!placement.lastInSection); + } else { + view.setSectionHeader(null); + view.setDividerVisible(true); + view.setCountView(null); + } + } + + protected void bindPhoto(final ContactListItemView view, int partitionIndex, Cursor cursor) { + if (!isPhotoSupported(partitionIndex)) { + view.removePhotoView(); + return; + } + + // Set the photo, if available + long photoId = 0; + if (!cursor.isNull(ContactQuery.CONTACT_PHOTO_ID)) { + photoId = cursor.getLong(ContactQuery.CONTACT_PHOTO_ID); + } + + if (photoId != 0) { + getPhotoLoader().loadThumbnail(view.getPhotoView(), photoId, false); + } else { + final String photoUriString = cursor.getString(ContactQuery.CONTACT_PHOTO_URI); + final Uri photoUri = photoUriString == null ? null : Uri.parse(photoUriString); + getPhotoLoader().loadDirectoryPhoto(view.getPhotoView(), photoUri, false); + } + } + + protected void bindName(final ContactListItemView view, Cursor cursor) { + view.showDisplayName( + cursor, ContactQuery.CONTACT_DISPLAY_NAME, getContactNameDisplayOrder()); + // Note: we don't show phonetic any more (See issue 5265330) + } + + protected void bindPresenceAndStatusMessage(final ContactListItemView view, Cursor cursor) { + view.showPresenceAndStatusMessage(cursor, ContactQuery.CONTACT_PRESENCE_STATUS, + ContactQuery.CONTACT_CONTACT_STATUS); + } + + protected void bindSearchSnippet(final ContactListItemView view, Cursor cursor) { + view.showSnippet(cursor, ContactQuery.CONTACT_SNIPPET); + } + + public int getSelectedContactPosition() { + if (mSelectedContactLookupKey == null && mSelectedContactId == 0) { + return -1; + } + + Cursor cursor = null; + int partitionIndex = -1; + int partitionCount = getPartitionCount(); + for (int i = 0; i < partitionCount; i++) { + DirectoryPartition partition = (DirectoryPartition) getPartition(i); + if (partition.getDirectoryId() == mSelectedContactDirectoryId) { + partitionIndex = i; + break; + } + } + if (partitionIndex == -1) { + return -1; + } + + cursor = getCursor(partitionIndex); + if (cursor == null) { + return -1; + } + + cursor.moveToPosition(-1); // Reset cursor + int offset = -1; + while (cursor.moveToNext()) { + if (mSelectedContactLookupKey != null) { + String lookupKey = cursor.getString(ContactQuery.CONTACT_LOOKUP_KEY); + if (mSelectedContactLookupKey.equals(lookupKey)) { + offset = cursor.getPosition(); + break; + } + } + if (mSelectedContactId != 0 && (mSelectedContactDirectoryId == Directory.DEFAULT + || mSelectedContactDirectoryId == Directory.LOCAL_INVISIBLE)) { + long contactId = cursor.getLong(ContactQuery.CONTACT_ID); + if (contactId == mSelectedContactId) { + offset = cursor.getPosition(); + break; + } + } + } + if (offset == -1) { + return -1; + } + + int position = getPositionForPartition(partitionIndex) + offset; + if (hasHeader(partitionIndex)) { + position++; + } + return position; + } + + public boolean hasValidSelection() { + return getSelectedContactPosition() != -1; + } + + public Uri getFirstContactUri() { + int partitionCount = getPartitionCount(); + for (int i = 0; i < partitionCount; i++) { + DirectoryPartition partition = (DirectoryPartition) getPartition(i); + if (partition.isLoading()) { + continue; + } + + Cursor cursor = getCursor(i); + if (cursor == null) { + continue; + } + + if (!cursor.moveToFirst()) { + continue; + } + + return getContactUri(i, cursor); + } + + return null; + } + + @Override + public void changeCursor(int partitionIndex, Cursor cursor) { + super.changeCursor(partitionIndex, cursor); + + // Check if a profile exists + if (cursor != null && cursor.getCount() > 0) { + cursor.moveToFirst(); + setProfileExists(cursor.getInt(ContactQuery.CONTACT_IS_USER_PROFILE) == 1); + } + } + + /** + * @return Projection useful for children. + */ + protected final String[] getProjection(boolean forSearch) { + final int sortOrder = getContactNameDisplayOrder(); + if (forSearch) { + if (sortOrder == ContactsContract.Preferences.DISPLAY_ORDER_PRIMARY) { + return ContactQuery.FILTER_PROJECTION_PRIMARY; + } else { + return ContactQuery.FILTER_PROJECTION_ALTERNATIVE; + } + } else { + if (sortOrder == ContactsContract.Preferences.DISPLAY_ORDER_PRIMARY) { + return ContactQuery.CONTACT_PROJECTION_PRIMARY; + } else { + return ContactQuery.CONTACT_PROJECTION_ALTERNATIVE; + } + } + } +} diff --git a/src/com/android/contacts/common/list/ContactListFilter.java b/src/com/android/contacts/common/list/ContactListFilter.java new file mode 100644 index 00000000..f81ea742 --- /dev/null +++ b/src/com/android/contacts/common/list/ContactListFilter.java @@ -0,0 +1,306 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.list; + +import android.content.SharedPreferences; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.provider.ContactsContract.RawContacts; +import android.text.TextUtils; + +/** + * Contact list filter parameters. + */ +public final class ContactListFilter implements Comparable<ContactListFilter>, Parcelable { + + public static final int FILTER_TYPE_DEFAULT = -1; + public static final int FILTER_TYPE_ALL_ACCOUNTS = -2; + public static final int FILTER_TYPE_CUSTOM = -3; + public static final int FILTER_TYPE_STARRED = -4; + public static final int FILTER_TYPE_WITH_PHONE_NUMBERS_ONLY = -5; + public static final int FILTER_TYPE_SINGLE_CONTACT = -6; + + public static final int FILTER_TYPE_ACCOUNT = 0; + + /** + * Obsolete filter which had been used in Honeycomb. This may be stored in + * {@link SharedPreferences}, but should be replaced with ALL filter when it is found. + * + * TODO: "group" filter and relevant variables are all obsolete. Remove them. + */ + private static final int FILTER_TYPE_GROUP = 1; + + private static final String KEY_FILTER_TYPE = "filter.type"; + private static final String KEY_ACCOUNT_NAME = "filter.accountName"; + private static final String KEY_ACCOUNT_TYPE = "filter.accountType"; + private static final String KEY_DATA_SET = "filter.dataSet"; + + public final int filterType; + public final String accountType; + public final String accountName; + public final String dataSet; + public final Drawable icon; + private String mId; + + public ContactListFilter(int filterType, String accountType, String accountName, String dataSet, + Drawable icon) { + this.filterType = filterType; + this.accountType = accountType; + this.accountName = accountName; + this.dataSet = dataSet; + this.icon = icon; + } + + public static ContactListFilter createFilterWithType(int filterType) { + return new ContactListFilter(filterType, null, null, null, null); + } + + public static ContactListFilter createAccountFilter(String accountType, String accountName, + String dataSet, Drawable icon) { + return new ContactListFilter(ContactListFilter.FILTER_TYPE_ACCOUNT, accountType, + accountName, dataSet, icon); + } + + /** + * Returns true if this filter is based on data and may become invalid over time. + */ + public boolean isValidationRequired() { + return filterType == FILTER_TYPE_ACCOUNT; + } + + @Override + public String toString() { + switch (filterType) { + case FILTER_TYPE_DEFAULT: + return "default"; + case FILTER_TYPE_ALL_ACCOUNTS: + return "all_accounts"; + case FILTER_TYPE_CUSTOM: + return "custom"; + case FILTER_TYPE_STARRED: + return "starred"; + case FILTER_TYPE_WITH_PHONE_NUMBERS_ONLY: + return "with_phones"; + case FILTER_TYPE_SINGLE_CONTACT: + return "single"; + case FILTER_TYPE_ACCOUNT: + return "account: " + accountType + (dataSet != null ? "/" + dataSet : "") + + " " + accountName; + } + return super.toString(); + } + + @Override + public int compareTo(ContactListFilter another) { + int res = accountName.compareTo(another.accountName); + if (res != 0) { + return res; + } + + res = accountType.compareTo(another.accountType); + if (res != 0) { + return res; + } + + return filterType - another.filterType; + } + + @Override + public int hashCode() { + int code = filterType; + if (accountType != null) { + code = code * 31 + accountType.hashCode(); + code = code * 31 + accountName.hashCode(); + } + if (dataSet != null) { + code = code * 31 + dataSet.hashCode(); + } + return code; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + + if (!(other instanceof ContactListFilter)) { + return false; + } + + ContactListFilter otherFilter = (ContactListFilter) other; + if (filterType != otherFilter.filterType + || !TextUtils.equals(accountName, otherFilter.accountName) + || !TextUtils.equals(accountType, otherFilter.accountType) + || !TextUtils.equals(dataSet, otherFilter.dataSet)) { + return false; + } + + return true; + } + + /** + * Store the given {@link ContactListFilter} to preferences. If the requested filter is + * of type {@link #FILTER_TYPE_SINGLE_CONTACT} then do not save it to preferences because + * it is a temporary state. + */ + public static void storeToPreferences(SharedPreferences prefs, ContactListFilter filter) { + if (filter != null && filter.filterType == FILTER_TYPE_SINGLE_CONTACT) { + return; + } + prefs.edit() + .putInt(KEY_FILTER_TYPE, filter == null ? FILTER_TYPE_DEFAULT : filter.filterType) + .putString(KEY_ACCOUNT_NAME, filter == null ? null : filter.accountName) + .putString(KEY_ACCOUNT_TYPE, filter == null ? null : filter.accountType) + .putString(KEY_DATA_SET, filter == null ? null : filter.dataSet) + .apply(); + } + + /** + * Try to obtain ContactListFilter object saved in SharedPreference. + * If there's no info there, return ALL filter instead. + */ + public static ContactListFilter restoreDefaultPreferences(SharedPreferences prefs) { + ContactListFilter filter = restoreFromPreferences(prefs); + if (filter == null) { + filter = ContactListFilter.createFilterWithType(FILTER_TYPE_ALL_ACCOUNTS); + } + // "Group" filter is obsolete and thus is not exposed anymore. The "single contact mode" + // should also not be stored in preferences anymore since it is a temporary state. + if (filter.filterType == FILTER_TYPE_GROUP || + filter.filterType == FILTER_TYPE_SINGLE_CONTACT) { + filter = ContactListFilter.createFilterWithType(FILTER_TYPE_ALL_ACCOUNTS); + } + return filter; + } + + private static ContactListFilter restoreFromPreferences(SharedPreferences prefs) { + int filterType = prefs.getInt(KEY_FILTER_TYPE, FILTER_TYPE_DEFAULT); + if (filterType == FILTER_TYPE_DEFAULT) { + return null; + } + + String accountName = prefs.getString(KEY_ACCOUNT_NAME, null); + String accountType = prefs.getString(KEY_ACCOUNT_TYPE, null); + String dataSet = prefs.getString(KEY_DATA_SET, null); + return new ContactListFilter(filterType, accountType, accountName, dataSet, null); + } + + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(filterType); + dest.writeString(accountName); + dest.writeString(accountType); + dest.writeString(dataSet); + } + + public static final Parcelable.Creator<ContactListFilter> CREATOR = + new Parcelable.Creator<ContactListFilter>() { + @Override + public ContactListFilter createFromParcel(Parcel source) { + int filterType = source.readInt(); + String accountName = source.readString(); + String accountType = source.readString(); + String dataSet = source.readString(); + return new ContactListFilter(filterType, accountType, accountName, dataSet, null); + } + + @Override + public ContactListFilter[] newArray(int size) { + return new ContactListFilter[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + /** + * Returns a string that can be used as a stable persistent identifier for this filter. + */ + public String getId() { + if (mId == null) { + StringBuilder sb = new StringBuilder(); + sb.append(filterType); + if (accountType != null) { + sb.append('-').append(accountType); + } + if (dataSet != null) { + sb.append('/').append(dataSet); + } + if (accountName != null) { + sb.append('-').append(accountName.replace('-', '_')); + } + mId = sb.toString(); + } + return mId; + } + + /** + * Adds the account query parameters to the given {@code uriBuilder}. + * + * @throws IllegalStateException if the filter type is not {@link #FILTER_TYPE_ACCOUNT}. + */ + public Uri.Builder addAccountQueryParameterToUrl(Uri.Builder uriBuilder) { + if (filterType != FILTER_TYPE_ACCOUNT) { + throw new IllegalStateException("filterType must be FILTER_TYPE_ACCOUNT"); + } + uriBuilder.appendQueryParameter(RawContacts.ACCOUNT_NAME, accountName); + uriBuilder.appendQueryParameter(RawContacts.ACCOUNT_TYPE, accountType); + if (!TextUtils.isEmpty(dataSet)) { + uriBuilder.appendQueryParameter(RawContacts.DATA_SET, dataSet); + } + return uriBuilder; + } + + public String toDebugString() { + final StringBuilder builder = new StringBuilder(); + builder.append("[filter type: " + filterType + " (" + filterTypeToString(filterType) + ")"); + if (filterType == FILTER_TYPE_ACCOUNT) { + builder.append(", accountType: " + accountType) + .append(", accountName: " + accountName) + .append(", dataSet: " + dataSet); + } + builder.append(", icon: " + icon + "]"); + return builder.toString(); + } + + public static final String filterTypeToString(int filterType) { + switch (filterType) { + case FILTER_TYPE_DEFAULT: + return "FILTER_TYPE_DEFAULT"; + case FILTER_TYPE_ALL_ACCOUNTS: + return "FILTER_TYPE_ALL_ACCOUNTS"; + case FILTER_TYPE_CUSTOM: + return "FILTER_TYPE_CUSTOM"; + case FILTER_TYPE_STARRED: + return "FILTER_TYPE_STARRED"; + case FILTER_TYPE_WITH_PHONE_NUMBERS_ONLY: + return "FILTER_TYPE_WITH_PHONE_NUMBERS_ONLY"; + case FILTER_TYPE_SINGLE_CONTACT: + return "FILTER_TYPE_SINGLE_CONTACT"; + case FILTER_TYPE_ACCOUNT: + return "FILTER_TYPE_ACCOUNT"; + default: + return "(unknown)"; + } + } +} diff --git a/src/com/android/contacts/common/list/ContactListItemView.java b/src/com/android/contacts/common/list/ContactListItemView.java new file mode 100644 index 00000000..d89aea9a --- /dev/null +++ b/src/com/android/contacts/common/list/ContactListItemView.java @@ -0,0 +1,1231 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.list; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.TypedArray; +import android.database.CharArrayBuffer; +import android.database.Cursor; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Rect; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.provider.ContactsContract; +import android.provider.ContactsContract.Contacts; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.TextUtils; +import android.text.TextUtils.TruncateAt; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AbsListView.SelectionBoundsAdjuster; +import android.widget.ImageView; +import android.widget.ImageView.ScaleType; +import android.widget.QuickContactBadge; +import android.widget.TextView; + +import com.android.contacts.common.ContactPresenceIconUtil; +import com.android.contacts.common.ContactStatusUtil; +import com.android.contacts.common.R; +import com.android.contacts.common.format.PrefixHighlighter; + +/** + * A custom view for an item in the contact list. + * The view contains the contact's photo, a set of text views (for name, status, etc...) and + * icons for presence and call. + * The view uses no XML file for layout and all the measurements and layouts are done + * in the onMeasure and onLayout methods. + * + * The layout puts the contact's photo on the right side of the view, the call icon (if present) + * to the left of the photo, the text lines are aligned to the left and the presence icon (if + * present) is set to the left of the status line. + * + * The layout also supports a header (used as a header of a group of contacts) that is above the + * contact's data and a divider between contact view. + */ + +public class ContactListItemView extends ViewGroup + implements SelectionBoundsAdjuster { + + // Style values for layout and appearance + // The initialized values are defaults if none is provided through xml. + private int mPreferredHeight = 0; + private int mGapBetweenImageAndText = 0; + private int mGapBetweenLabelAndData = 0; + private int mPresenceIconMargin = 4; + private int mPresenceIconSize = 16; + private int mHeaderTextColor = Color.BLACK; + private int mHeaderTextIndent = 0; + private int mHeaderTextSize = 12; + private int mHeaderUnderlineHeight = 1; + private int mHeaderUnderlineColor = 0; + private int mCountViewTextSize = 12; + private int mContactsCountTextColor = Color.BLACK; + private int mTextIndent = 0; + private Drawable mActivatedBackgroundDrawable; + + /** + * Used with {@link #mLabelView}, specifying the width ratio between label and data. + */ + private int mLabelViewWidthWeight = 3; + /** + * Used with {@link #mDataView}, specifying the width ratio between label and data. + */ + private int mDataViewWidthWeight = 5; + + // Will be used with adjustListItemSelectionBounds(). + private int mSelectionBoundsMarginLeft; + private int mSelectionBoundsMarginRight; + + // Horizontal divider between contact views. + private boolean mHorizontalDividerVisible = true; + private Drawable mHorizontalDividerDrawable; + private int mHorizontalDividerHeight; + + /** + * Where to put contact photo. This affects the other Views' layout or look-and-feel. + */ + public enum PhotoPosition { + LEFT, + RIGHT + } + public static final PhotoPosition DEFAULT_PHOTO_POSITION = PhotoPosition.RIGHT; + private PhotoPosition mPhotoPosition = DEFAULT_PHOTO_POSITION; + + // Header layout data + private boolean mHeaderVisible; + private View mHeaderDivider; + private int mHeaderBackgroundHeight = 30; + private TextView mHeaderTextView; + + // The views inside the contact view + private boolean mQuickContactEnabled = true; + private QuickContactBadge mQuickContact; + private ImageView mPhotoView; + private TextView mNameTextView; + private TextView mPhoneticNameTextView; + private TextView mLabelView; + private TextView mDataView; + private TextView mSnippetView; + private TextView mStatusView; + private TextView mCountView; + private ImageView mPresenceIcon; + + private ColorStateList mSecondaryTextColor; + + private char[] mHighlightedPrefix; + + private int mDefaultPhotoViewSize = 0; + /** + * Can be effective even when {@link #mPhotoView} is null, as we want to have horizontal padding + * to align other data in this View. + */ + private int mPhotoViewWidth; + /** + * Can be effective even when {@link #mPhotoView} is null, as we want to have vertical padding. + */ + private int mPhotoViewHeight; + + /** + * Only effective when {@link #mPhotoView} is null. + * When true all the Views on the right side of the photo should have horizontal padding on + * those left assuming there is a photo. + */ + private boolean mKeepHorizontalPaddingForPhotoView; + /** + * Only effective when {@link #mPhotoView} is null. + */ + private boolean mKeepVerticalPaddingForPhotoView; + + /** + * True when {@link #mPhotoViewWidth} and {@link #mPhotoViewHeight} are ready for being used. + * False indicates those values should be updated before being used in position calculation. + */ + private boolean mPhotoViewWidthAndHeightAreReady = false; + + private int mNameTextViewHeight; + private int mPhoneticNameTextViewHeight; + private int mLabelViewHeight; + private int mDataViewHeight; + private int mSnippetTextViewHeight; + private int mStatusTextViewHeight; + + // Holds Math.max(mLabelTextViewHeight, mDataViewHeight), assuming Label and Data share the + // same row. + private int mLabelAndDataViewMaxHeight; + + // TODO: some TextView fields are using CharArrayBuffer while some are not. Determine which is + // more efficient for each case or in general, and simplify the whole implementation. + // Note: if we're sure MARQUEE will be used every time, there's no reason to use + // CharArrayBuffer, since MARQUEE requires Span and thus we need to copy characters inside the + // buffer to Spannable once, while CharArrayBuffer is for directly applying char array to + // TextView without any modification. + private final CharArrayBuffer mDataBuffer = new CharArrayBuffer(128); + private final CharArrayBuffer mPhoneticNameBuffer = new CharArrayBuffer(128); + + private boolean mActivatedStateSupported; + + private Rect mBoundsWithoutHeader = new Rect(); + + /** A helper used to highlight a prefix in a text field. */ + private PrefixHighlighter mPrefixHighlighter; + private CharSequence mUnknownNameText; + + public ContactListItemView(Context context) { + super(context); + mContext = context; + + mPrefixHighlighter = new PrefixHighlighter(Color.GREEN); + } + + public ContactListItemView(Context context, AttributeSet attrs) { + super(context, attrs); + mContext = context; + + // Read all style values + TypedArray a = getContext().obtainStyledAttributes(attrs, R.styleable.ContactListItemView); + mPreferredHeight = a.getDimensionPixelSize( + R.styleable.ContactListItemView_list_item_height, mPreferredHeight); + mActivatedBackgroundDrawable = a.getDrawable( + R.styleable.ContactListItemView_activated_background); + mHorizontalDividerDrawable = a.getDrawable( + R.styleable.ContactListItemView_list_item_divider); + + mGapBetweenImageAndText = a.getDimensionPixelOffset( + R.styleable.ContactListItemView_list_item_gap_between_image_and_text, + mGapBetweenImageAndText); + mGapBetweenLabelAndData = a.getDimensionPixelOffset( + R.styleable.ContactListItemView_list_item_gap_between_label_and_data, + mGapBetweenLabelAndData); + mPresenceIconMargin = a.getDimensionPixelOffset( + R.styleable.ContactListItemView_list_item_presence_icon_margin, + mPresenceIconMargin); + mPresenceIconSize = a.getDimensionPixelOffset( + R.styleable.ContactListItemView_list_item_presence_icon_size, mPresenceIconSize); + mDefaultPhotoViewSize = a.getDimensionPixelOffset( + R.styleable.ContactListItemView_list_item_photo_size, mDefaultPhotoViewSize); + mHeaderTextIndent = a.getDimensionPixelOffset( + R.styleable.ContactListItemView_list_item_header_text_indent, mHeaderTextIndent); + mHeaderTextColor = a.getColor( + R.styleable.ContactListItemView_list_item_header_text_color, mHeaderTextColor); + mHeaderTextSize = a.getDimensionPixelSize( + R.styleable.ContactListItemView_list_item_header_text_size, mHeaderTextSize); + mHeaderBackgroundHeight = a.getDimensionPixelSize( + R.styleable.ContactListItemView_list_item_header_height, mHeaderBackgroundHeight); + mHeaderUnderlineHeight = a.getDimensionPixelSize( + R.styleable.ContactListItemView_list_item_header_underline_height, + mHeaderUnderlineHeight); + mHeaderUnderlineColor = a.getColor( + R.styleable.ContactListItemView_list_item_header_underline_color, + mHeaderUnderlineColor); + mTextIndent = a.getDimensionPixelOffset( + R.styleable.ContactListItemView_list_item_text_indent, mTextIndent); + mCountViewTextSize = a.getDimensionPixelSize( + R.styleable.ContactListItemView_list_item_contacts_count_text_size, + mCountViewTextSize); + mContactsCountTextColor = a.getColor( + R.styleable.ContactListItemView_list_item_contacts_count_text_color, + mContactsCountTextColor); + mDataViewWidthWeight = a.getInteger( + R.styleable.ContactListItemView_list_item_data_width_weight, mDataViewWidthWeight); + mLabelViewWidthWeight = a.getInteger( + R.styleable.ContactListItemView_list_item_label_width_weight, + mLabelViewWidthWeight); + + setPadding( + a.getDimensionPixelOffset( + R.styleable.ContactListItemView_list_item_padding_left, 0), + a.getDimensionPixelOffset( + R.styleable.ContactListItemView_list_item_padding_top, 0), + a.getDimensionPixelOffset( + R.styleable.ContactListItemView_list_item_padding_right, 0), + a.getDimensionPixelOffset( + R.styleable.ContactListItemView_list_item_padding_bottom, 0)); + + final int prefixHighlightColor = a.getColor( + R.styleable.ContactListItemView_list_item_prefix_highlight_color, Color.GREEN); + mPrefixHighlighter = new PrefixHighlighter(prefixHighlightColor); + a.recycle(); + + a = getContext().obtainStyledAttributes(android.R.styleable.Theme); + mSecondaryTextColor = a.getColorStateList(android.R.styleable.Theme_textColorSecondary); + a.recycle(); + + mHorizontalDividerHeight = mHorizontalDividerDrawable.getIntrinsicHeight(); + + if (mActivatedBackgroundDrawable != null) { + mActivatedBackgroundDrawable.setCallback(this); + } + } + + public void setUnknownNameText(CharSequence unknownNameText) { + mUnknownNameText = unknownNameText; + } + + public void setQuickContactEnabled(boolean flag) { + mQuickContactEnabled = flag; + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + // We will match parent's width and wrap content vertically, but make sure + // height is no less than listPreferredItemHeight. + final int specWidth = resolveSize(0, widthMeasureSpec); + final int preferredHeight; + if (mHorizontalDividerVisible) { + preferredHeight = mPreferredHeight + mHorizontalDividerHeight; + } else { + preferredHeight = mPreferredHeight; + } + + mNameTextViewHeight = 0; + mPhoneticNameTextViewHeight = 0; + mLabelViewHeight = 0; + mDataViewHeight = 0; + mLabelAndDataViewMaxHeight = 0; + mSnippetTextViewHeight = 0; + mStatusTextViewHeight = 0; + + ensurePhotoViewSize(); + + // Width each TextView is able to use. + final int effectiveWidth; + // All the other Views will honor the photo, so available width for them may be shrunk. + if (mPhotoViewWidth > 0 || mKeepHorizontalPaddingForPhotoView) { + effectiveWidth = specWidth - getPaddingLeft() - getPaddingRight() + - (mPhotoViewWidth + mGapBetweenImageAndText); + } else { + effectiveWidth = specWidth - getPaddingLeft() - getPaddingRight(); + } + + // Go over all visible text views and measure actual width of each of them. + // Also calculate their heights to get the total height for this entire view. + + if (isVisible(mNameTextView)) { + // Caculate width for name text - this parallels similar measurement in onLayout. + int nameTextWidth = effectiveWidth; + if (mPhotoPosition != PhotoPosition.LEFT) { + nameTextWidth -= mTextIndent; + } + mNameTextView.measure( + MeasureSpec.makeMeasureSpec(nameTextWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); + mNameTextViewHeight = mNameTextView.getMeasuredHeight(); + } + + if (isVisible(mPhoneticNameTextView)) { + mPhoneticNameTextView.measure( + MeasureSpec.makeMeasureSpec(effectiveWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); + mPhoneticNameTextViewHeight = mPhoneticNameTextView.getMeasuredHeight(); + } + + // If both data (phone number/email address) and label (type like "MOBILE") are quite long, + // we should ellipsize both using appropriate ratio. + final int dataWidth; + final int labelWidth; + if (isVisible(mDataView)) { + if (isVisible(mLabelView)) { + final int totalWidth = effectiveWidth - mGapBetweenLabelAndData; + dataWidth = ((totalWidth * mDataViewWidthWeight) + / (mDataViewWidthWeight + mLabelViewWidthWeight)); + labelWidth = ((totalWidth * mLabelViewWidthWeight) / + (mDataViewWidthWeight + mLabelViewWidthWeight)); + } else { + dataWidth = effectiveWidth; + labelWidth = 0; + } + } else { + dataWidth = 0; + if (isVisible(mLabelView)) { + labelWidth = effectiveWidth; + } else { + labelWidth = 0; + } + } + + if (isVisible(mDataView)) { + mDataView.measure(MeasureSpec.makeMeasureSpec(dataWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); + mDataViewHeight = mDataView.getMeasuredHeight(); + } + + if (isVisible(mLabelView)) { + // For performance reason we don't want AT_MOST usually, but when the picture is + // on right, we need to use it anyway because mDataView is next to mLabelView. + final int mode = (mPhotoPosition == PhotoPosition.LEFT + ? MeasureSpec.EXACTLY : MeasureSpec.AT_MOST); + mLabelView.measure(MeasureSpec.makeMeasureSpec(labelWidth, mode), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); + mLabelViewHeight = mLabelView.getMeasuredHeight(); + } + mLabelAndDataViewMaxHeight = Math.max(mLabelViewHeight, mDataViewHeight); + + if (isVisible(mSnippetView)) { + mSnippetView.measure( + MeasureSpec.makeMeasureSpec(effectiveWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); + mSnippetTextViewHeight = mSnippetView.getMeasuredHeight(); + } + + // Status view height is the biggest of the text view and the presence icon + if (isVisible(mPresenceIcon)) { + mPresenceIcon.measure( + MeasureSpec.makeMeasureSpec(mPresenceIconSize, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(mPresenceIconSize, MeasureSpec.EXACTLY)); + mStatusTextViewHeight = mPresenceIcon.getMeasuredHeight(); + } + + if (isVisible(mStatusView)) { + // Presence and status are in a same row, so status will be affected by icon size. + final int statusWidth; + if (isVisible(mPresenceIcon)) { + statusWidth = (effectiveWidth - mPresenceIcon.getMeasuredWidth() + - mPresenceIconMargin); + } else { + statusWidth = effectiveWidth; + } + mStatusView.measure(MeasureSpec.makeMeasureSpec(statusWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); + mStatusTextViewHeight = + Math.max(mStatusTextViewHeight, mStatusView.getMeasuredHeight()); + } + + // Calculate height including padding. + int height = (mNameTextViewHeight + mPhoneticNameTextViewHeight + + mLabelAndDataViewMaxHeight + + mSnippetTextViewHeight + mStatusTextViewHeight); + + // Make sure the height is at least as high as the photo + height = Math.max(height, mPhotoViewHeight + getPaddingBottom() + getPaddingTop()); + + // Add horizontal divider height + if (mHorizontalDividerVisible) { + height += mHorizontalDividerHeight; + } + + // Make sure height is at least the preferred height + height = Math.max(height, preferredHeight); + + // Add the height of the header if visible + if (mHeaderVisible) { + mHeaderTextView.measure( + MeasureSpec.makeMeasureSpec(specWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(mHeaderBackgroundHeight, MeasureSpec.EXACTLY)); + if (mCountView != null) { + mCountView.measure( + MeasureSpec.makeMeasureSpec(specWidth, MeasureSpec.AT_MOST), + MeasureSpec.makeMeasureSpec(mHeaderBackgroundHeight, MeasureSpec.EXACTLY)); + } + mHeaderBackgroundHeight = Math.max(mHeaderBackgroundHeight, + mHeaderTextView.getMeasuredHeight()); + height += (mHeaderBackgroundHeight + mHeaderUnderlineHeight); + } + + setMeasuredDimension(specWidth, height); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + final int height = bottom - top; + final int width = right - left; + + // Determine the vertical bounds by laying out the header first. + int topBound = 0; + int bottomBound = height; + int leftBound = getPaddingLeft(); + int rightBound = width - getPaddingRight(); + + // Put the header in the top of the contact view (Text + underline view) + if (mHeaderVisible) { + mHeaderTextView.layout(leftBound + mHeaderTextIndent, + 0, + rightBound, + mHeaderBackgroundHeight); + if (mCountView != null) { + mCountView.layout(rightBound - mCountView.getMeasuredWidth(), + 0, + rightBound, + mHeaderBackgroundHeight); + } + mHeaderDivider.layout(leftBound, + mHeaderBackgroundHeight, + rightBound, + mHeaderBackgroundHeight + mHeaderUnderlineHeight); + topBound += (mHeaderBackgroundHeight + mHeaderUnderlineHeight); + } + + // Put horizontal divider at the bottom + if (mHorizontalDividerVisible) { + mHorizontalDividerDrawable.setBounds( + leftBound, + height - mHorizontalDividerHeight, + rightBound, + height); + bottomBound -= mHorizontalDividerHeight; + } + + mBoundsWithoutHeader.set(0, topBound, width, bottomBound); + + if (mActivatedStateSupported && isActivated()) { + mActivatedBackgroundDrawable.setBounds(mBoundsWithoutHeader); + } + + final View photoView = mQuickContact != null ? mQuickContact : mPhotoView; + if (mPhotoPosition == PhotoPosition.LEFT) { + // Photo is the left most view. All the other Views should on the right of the photo. + if (photoView != null) { + // Center the photo vertically + final int photoTop = topBound + (bottomBound - topBound - mPhotoViewHeight) / 2; + photoView.layout( + leftBound, + photoTop, + leftBound + mPhotoViewWidth, + photoTop + mPhotoViewHeight); + leftBound += mPhotoViewWidth + mGapBetweenImageAndText; + } else if (mKeepHorizontalPaddingForPhotoView) { + // Draw nothing but keep the padding. + leftBound += mPhotoViewWidth + mGapBetweenImageAndText; + } + } else { + // Photo is the right most view. Right bound should be adjusted that way. + if (photoView != null) { + // Center the photo vertically + final int photoTop = topBound + (bottomBound - topBound - mPhotoViewHeight) / 2; + photoView.layout( + rightBound - mPhotoViewWidth, + photoTop, + rightBound, + photoTop + mPhotoViewHeight); + rightBound -= (mPhotoViewWidth + mGapBetweenImageAndText); + } + + // Add indent between left-most padding and texts. + leftBound += mTextIndent; + } + + // Center text vertically + final int totalTextHeight = mNameTextViewHeight + mPhoneticNameTextViewHeight + + mLabelAndDataViewMaxHeight + mSnippetTextViewHeight + mStatusTextViewHeight; + int textTopBound = (bottomBound + topBound - totalTextHeight) / 2; + + // Layout all text view and presence icon + // Put name TextView first + if (isVisible(mNameTextView)) { + mNameTextView.layout(leftBound, + textTopBound, + rightBound, + textTopBound + mNameTextViewHeight); + textTopBound += mNameTextViewHeight; + } + + // Presence and status + int statusLeftBound = leftBound; + if (isVisible(mPresenceIcon)) { + int iconWidth = mPresenceIcon.getMeasuredWidth(); + mPresenceIcon.layout( + leftBound, + textTopBound, + leftBound + iconWidth, + textTopBound + mStatusTextViewHeight); + statusLeftBound += (iconWidth + mPresenceIconMargin); + } + + if (isVisible(mStatusView)) { + mStatusView.layout(statusLeftBound, + textTopBound, + rightBound, + textTopBound + mStatusTextViewHeight); + } + + if (isVisible(mStatusView) || isVisible(mPresenceIcon)) { + textTopBound += mStatusTextViewHeight; + } + + // Rest of text views + int dataLeftBound = leftBound; + if (isVisible(mPhoneticNameTextView)) { + mPhoneticNameTextView.layout(leftBound, + textTopBound, + rightBound, + textTopBound + mPhoneticNameTextViewHeight); + textTopBound += mPhoneticNameTextViewHeight; + } + + // Label and Data align bottom. + if (isVisible(mLabelView)) { + if (mPhotoPosition == PhotoPosition.LEFT) { + // When photo is on left, label is placed on the right edge of the list item. + mLabelView.layout(rightBound - mLabelView.getMeasuredWidth(), + textTopBound + mLabelAndDataViewMaxHeight - mLabelViewHeight, + rightBound, + textTopBound + mLabelAndDataViewMaxHeight); + rightBound -= mLabelView.getMeasuredWidth(); + } else { + // When photo is on right, label is placed on the left of data view. + dataLeftBound = leftBound + mLabelView.getMeasuredWidth(); + mLabelView.layout(leftBound, + textTopBound + mLabelAndDataViewMaxHeight - mLabelViewHeight, + dataLeftBound, + textTopBound + mLabelAndDataViewMaxHeight); + dataLeftBound += mGapBetweenLabelAndData; + } + } + + if (isVisible(mDataView)) { + mDataView.layout(dataLeftBound, + textTopBound + mLabelAndDataViewMaxHeight - mDataViewHeight, + rightBound, + textTopBound + mLabelAndDataViewMaxHeight); + } + if (isVisible(mLabelView) || isVisible(mDataView)) { + textTopBound += mLabelAndDataViewMaxHeight; + } + + if (isVisible(mSnippetView)) { + mSnippetView.layout(leftBound, + textTopBound, + rightBound, + textTopBound + mSnippetTextViewHeight); + } + } + + @Override + public void adjustListItemSelectionBounds(Rect bounds) { + bounds.top += mBoundsWithoutHeader.top; + bounds.bottom = bounds.top + mBoundsWithoutHeader.height(); + bounds.left += mSelectionBoundsMarginLeft; + bounds.right -= mSelectionBoundsMarginRight; + } + + protected boolean isVisible(View view) { + return view != null && view.getVisibility() == View.VISIBLE; + } + + /** + * Extracts width and height from the style + */ + private void ensurePhotoViewSize() { + if (!mPhotoViewWidthAndHeightAreReady) { + mPhotoViewWidth = mPhotoViewHeight = getDefaultPhotoViewSize(); + if (!mQuickContactEnabled && mPhotoView == null) { + if (!mKeepHorizontalPaddingForPhotoView) { + mPhotoViewWidth = 0; + } + if (!mKeepVerticalPaddingForPhotoView) { + mPhotoViewHeight = 0; + } + } + + mPhotoViewWidthAndHeightAreReady = true; + } + } + + protected void setDefaultPhotoViewSize(int pixels) { + mDefaultPhotoViewSize = pixels; + } + + protected int getDefaultPhotoViewSize() { + return mDefaultPhotoViewSize; + } + + /** + * Gets a LayoutParam that corresponds to the default photo size. + * + * @return A new LayoutParam. + */ + private LayoutParams getDefaultPhotoLayoutParams() { + LayoutParams params = generateDefaultLayoutParams(); + params.width = getDefaultPhotoViewSize(); + params.height = params.width; + return params; + } + + @Override + protected void drawableStateChanged() { + super.drawableStateChanged(); + if (mActivatedStateSupported) { + mActivatedBackgroundDrawable.setState(getDrawableState()); + } + } + + @Override + protected boolean verifyDrawable(Drawable who) { + return who == mActivatedBackgroundDrawable || super.verifyDrawable(who); + } + + @Override + public void jumpDrawablesToCurrentState() { + super.jumpDrawablesToCurrentState(); + if (mActivatedStateSupported) { + mActivatedBackgroundDrawable.jumpToCurrentState(); + } + } + + @Override + public void dispatchDraw(Canvas canvas) { + if (mActivatedStateSupported && isActivated()) { + mActivatedBackgroundDrawable.draw(canvas); + } + if (mHorizontalDividerVisible) { + mHorizontalDividerDrawable.draw(canvas); + } + + super.dispatchDraw(canvas); + } + + /** + * Sets the flag that determines whether a divider should drawn at the bottom + * of the view. + */ + public void setDividerVisible(boolean visible) { + mHorizontalDividerVisible = visible; + } + + /** + * Sets section header or makes it invisible if the title is null. + */ + public void setSectionHeader(String title) { + if (!TextUtils.isEmpty(title)) { + if (mHeaderTextView == null) { + mHeaderTextView = new TextView(mContext); + mHeaderTextView.setTextColor(mHeaderTextColor); + mHeaderTextView.setTextSize(TypedValue.COMPLEX_UNIT_PX, mHeaderTextSize); + mHeaderTextView.setTypeface(mHeaderTextView.getTypeface(), Typeface.BOLD); + mHeaderTextView.setGravity(Gravity.CENTER_VERTICAL); + addView(mHeaderTextView); + } + if (mHeaderDivider == null) { + mHeaderDivider = new View(mContext); + mHeaderDivider.setBackgroundColor(mHeaderUnderlineColor); + addView(mHeaderDivider); + } + setMarqueeText(mHeaderTextView, title); + mHeaderTextView.setVisibility(View.VISIBLE); + mHeaderDivider.setVisibility(View.VISIBLE); + mHeaderTextView.setAllCaps(true); + mHeaderVisible = true; + } else { + if (mHeaderTextView != null) { + mHeaderTextView.setVisibility(View.GONE); + } + if (mHeaderDivider != null) { + mHeaderDivider.setVisibility(View.GONE); + } + mHeaderVisible = false; + } + } + + /** + * Returns the quick contact badge, creating it if necessary. + */ + public QuickContactBadge getQuickContact() { + if (!mQuickContactEnabled) { + throw new IllegalStateException("QuickContact is disabled for this view"); + } + if (mQuickContact == null) { + mQuickContact = new QuickContactBadge(mContext); + mQuickContact.setLayoutParams(getDefaultPhotoLayoutParams()); + if (mNameTextView != null) { + mQuickContact.setContentDescription(mContext.getString( + R.string.description_quick_contact_for, mNameTextView.getText())); + } + + addView(mQuickContact); + mPhotoViewWidthAndHeightAreReady = false; + } + return mQuickContact; + } + + /** + * Returns the photo view, creating it if necessary. + */ + public ImageView getPhotoView() { + if (mPhotoView == null) { + mPhotoView = new ImageView(mContext); + mPhotoView.setLayoutParams(getDefaultPhotoLayoutParams()); + // Quick contact style used above will set a background - remove it + mPhotoView.setBackground(null); + addView(mPhotoView); + mPhotoViewWidthAndHeightAreReady = false; + } + return mPhotoView; + } + + /** + * Removes the photo view. + */ + public void removePhotoView() { + removePhotoView(false, true); + } + + /** + * Removes the photo view. + * + * @param keepHorizontalPadding True means data on the right side will have + * padding on left, pretending there is still a photo view. + * @param keepVerticalPadding True means the View will have some height + * enough for accommodating a photo view. + */ + public void removePhotoView(boolean keepHorizontalPadding, boolean keepVerticalPadding) { + mPhotoViewWidthAndHeightAreReady = false; + mKeepHorizontalPaddingForPhotoView = keepHorizontalPadding; + mKeepVerticalPaddingForPhotoView = keepVerticalPadding; + if (mPhotoView != null) { + removeView(mPhotoView); + mPhotoView = null; + } + if (mQuickContact != null) { + removeView(mQuickContact); + mQuickContact = null; + } + } + + /** + * Sets a word prefix that will be highlighted if encountered in fields like + * name and search snippet. + * <p> + * NOTE: must be all upper-case + */ + public void setHighlightedPrefix(char[] upperCasePrefix) { + mHighlightedPrefix = upperCasePrefix; + } + + /** + * Returns the text view for the contact name, creating it if necessary. + */ + public TextView getNameTextView() { + if (mNameTextView == null) { + mNameTextView = new TextView(mContext); + mNameTextView.setSingleLine(true); + mNameTextView.setEllipsize(getTextEllipsis()); + mNameTextView.setTextAppearance(mContext, android.R.style.TextAppearance_Medium); + // Manually call setActivated() since this view may be added after the first + // setActivated() call toward this whole item view. + mNameTextView.setActivated(isActivated()); + mNameTextView.setGravity(Gravity.CENTER_VERTICAL); + addView(mNameTextView); + } + return mNameTextView; + } + + /** + * Adds or updates a text view for the phonetic name. + */ + public void setPhoneticName(char[] text, int size) { + if (text == null || size == 0) { + if (mPhoneticNameTextView != null) { + mPhoneticNameTextView.setVisibility(View.GONE); + } + } else { + getPhoneticNameTextView(); + setMarqueeText(mPhoneticNameTextView, text, size); + mPhoneticNameTextView.setVisibility(VISIBLE); + } + } + + /** + * Returns the text view for the phonetic name, creating it if necessary. + */ + public TextView getPhoneticNameTextView() { + if (mPhoneticNameTextView == null) { + mPhoneticNameTextView = new TextView(mContext); + mPhoneticNameTextView.setSingleLine(true); + mPhoneticNameTextView.setEllipsize(getTextEllipsis()); + mPhoneticNameTextView.setTextAppearance(mContext, android.R.style.TextAppearance_Small); + mPhoneticNameTextView.setTypeface(mPhoneticNameTextView.getTypeface(), Typeface.BOLD); + mPhoneticNameTextView.setActivated(isActivated()); + addView(mPhoneticNameTextView); + } + return mPhoneticNameTextView; + } + + /** + * Adds or updates a text view for the data label. + */ + public void setLabel(CharSequence text) { + if (TextUtils.isEmpty(text)) { + if (mLabelView != null) { + mLabelView.setVisibility(View.GONE); + } + } else { + getLabelView(); + setMarqueeText(mLabelView, text); + mLabelView.setVisibility(VISIBLE); + } + } + + /** + * Returns the text view for the data label, creating it if necessary. + */ + public TextView getLabelView() { + if (mLabelView == null) { + mLabelView = new TextView(mContext); + mLabelView.setSingleLine(true); + mLabelView.setEllipsize(getTextEllipsis()); + mLabelView.setTextAppearance(mContext, android.R.style.TextAppearance_Small); + if (mPhotoPosition == PhotoPosition.LEFT) { + mLabelView.setTextSize(TypedValue.COMPLEX_UNIT_SP, mCountViewTextSize); + mLabelView.setAllCaps(true); + mLabelView.setGravity(Gravity.RIGHT); + } else { + mLabelView.setTypeface(mLabelView.getTypeface(), Typeface.BOLD); + } + mLabelView.setActivated(isActivated()); + addView(mLabelView); + } + return mLabelView; + } + + /** + * Adds or updates a text view for the data element. + */ + public void setData(char[] text, int size) { + if (text == null || size == 0) { + if (mDataView != null) { + mDataView.setVisibility(View.GONE); + } + } else { + getDataView(); + setMarqueeText(mDataView, text, size); + mDataView.setVisibility(VISIBLE); + } + } + + private void setMarqueeText(TextView textView, char[] text, int size) { + if (getTextEllipsis() == TruncateAt.MARQUEE) { + setMarqueeText(textView, new String(text, 0, size)); + } else { + textView.setText(text, 0, size); + } + } + + private void setMarqueeText(TextView textView, CharSequence text) { + if (getTextEllipsis() == TruncateAt.MARQUEE) { + // To show MARQUEE correctly (with END effect during non-active state), we need + // to build Spanned with MARQUEE in addition to TextView's ellipsize setting. + final SpannableString spannable = new SpannableString(text); + spannable.setSpan(TruncateAt.MARQUEE, 0, spannable.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + textView.setText(spannable); + } else { + textView.setText(text); + } + } + + /** + * Returns the text view for the data text, creating it if necessary. + */ + public TextView getDataView() { + if (mDataView == null) { + mDataView = new TextView(mContext); + mDataView.setSingleLine(true); + mDataView.setEllipsize(getTextEllipsis()); + mDataView.setTextAppearance(mContext, android.R.style.TextAppearance_Small); + mDataView.setActivated(isActivated()); + addView(mDataView); + } + return mDataView; + } + + /** + * Adds or updates a text view for the search snippet. + */ + public void setSnippet(String text) { + if (TextUtils.isEmpty(text)) { + if (mSnippetView != null) { + mSnippetView.setVisibility(View.GONE); + } + } else { + mPrefixHighlighter.setText(getSnippetView(), text, mHighlightedPrefix); + mSnippetView.setVisibility(VISIBLE); + } + } + + /** + * Returns the text view for the search snippet, creating it if necessary. + */ + public TextView getSnippetView() { + if (mSnippetView == null) { + mSnippetView = new TextView(mContext); + mSnippetView.setSingleLine(true); + mSnippetView.setEllipsize(getTextEllipsis()); + mSnippetView.setTextAppearance(mContext, android.R.style.TextAppearance_Small); + mSnippetView.setTypeface(mSnippetView.getTypeface(), Typeface.BOLD); + mSnippetView.setActivated(isActivated()); + addView(mSnippetView); + } + return mSnippetView; + } + + /** + * Returns the text view for the status, creating it if necessary. + */ + public TextView getStatusView() { + if (mStatusView == null) { + mStatusView = new TextView(mContext); + mStatusView.setSingleLine(true); + mStatusView.setEllipsize(getTextEllipsis()); + mStatusView.setTextAppearance(mContext, android.R.style.TextAppearance_Small); + mStatusView.setTextColor(mSecondaryTextColor); + mStatusView.setActivated(isActivated()); + addView(mStatusView); + } + return mStatusView; + } + + /** + * Returns the text view for the contacts count, creating it if necessary. + */ + public TextView getCountView() { + if (mCountView == null) { + mCountView = new TextView(mContext); + mCountView.setSingleLine(true); + mCountView.setEllipsize(getTextEllipsis()); + mCountView.setTextAppearance(mContext, android.R.style.TextAppearance_Medium); + mCountView.setTextColor(R.color.contact_count_text_color); + addView(mCountView); + } + return mCountView; + } + + /** + * Adds or updates a text view for the contacts count. + */ + public void setCountView(CharSequence text) { + if (TextUtils.isEmpty(text)) { + if (mCountView != null) { + mCountView.setVisibility(View.GONE); + } + } else { + getCountView(); + setMarqueeText(mCountView, text); + mCountView.setTextSize(TypedValue.COMPLEX_UNIT_PX, mCountViewTextSize); + mCountView.setGravity(Gravity.CENTER_VERTICAL); + mCountView.setTextColor(mContactsCountTextColor); + mCountView.setVisibility(VISIBLE); + } + } + + /** + * Adds or updates a text view for the status. + */ + public void setStatus(CharSequence text) { + if (TextUtils.isEmpty(text)) { + if (mStatusView != null) { + mStatusView.setVisibility(View.GONE); + } + } else { + getStatusView(); + setMarqueeText(mStatusView, text); + mStatusView.setVisibility(VISIBLE); + } + } + + /** + * Adds or updates the presence icon view. + */ + public void setPresence(Drawable icon) { + if (icon != null) { + if (mPresenceIcon == null) { + mPresenceIcon = new ImageView(mContext); + addView(mPresenceIcon); + } + mPresenceIcon.setImageDrawable(icon); + mPresenceIcon.setScaleType(ScaleType.CENTER); + mPresenceIcon.setVisibility(View.VISIBLE); + } else { + if (mPresenceIcon != null) { + mPresenceIcon.setVisibility(View.GONE); + } + } + } + + private TruncateAt getTextEllipsis() { + return TruncateAt.MARQUEE; + } + + public void showDisplayName(Cursor cursor, int nameColumnIndex, int displayOrder) { + CharSequence name = cursor.getString(nameColumnIndex); + if (!TextUtils.isEmpty(name)) { + name = mPrefixHighlighter.apply(name, mHighlightedPrefix); + } else { + name = mUnknownNameText; + } + setMarqueeText(getNameTextView(), name); + + // Since the quick contact content description is derived from the display name and there is + // no guarantee that when the quick contact is initialized the display name is already set, + // do it here too. + if (mQuickContact != null) { + mQuickContact.setContentDescription(mContext.getString( + R.string.description_quick_contact_for, mNameTextView.getText())); + } + } + + public void hideDisplayName() { + if (mNameTextView != null) { + removeView(mNameTextView); + mNameTextView = null; + } + } + + public void showPhoneticName(Cursor cursor, int phoneticNameColumnIndex) { + cursor.copyStringToBuffer(phoneticNameColumnIndex, mPhoneticNameBuffer); + int phoneticNameSize = mPhoneticNameBuffer.sizeCopied; + if (phoneticNameSize != 0) { + setPhoneticName(mPhoneticNameBuffer.data, phoneticNameSize); + } else { + setPhoneticName(null, 0); + } + } + + public void hidePhoneticName() { + if (mPhoneticNameTextView != null) { + removeView(mPhoneticNameTextView); + mPhoneticNameTextView = null; + } + } + + /** + * Sets the proper icon (star or presence or nothing) and/or status message. + */ + public void showPresenceAndStatusMessage(Cursor cursor, int presenceColumnIndex, + int contactStatusColumnIndex) { + Drawable icon = null; + int presence = 0; + if (!cursor.isNull(presenceColumnIndex)) { + presence = cursor.getInt(presenceColumnIndex); + icon = ContactPresenceIconUtil.getPresenceIcon(getContext(), presence); + } + setPresence(icon); + + String statusMessage = null; + if (contactStatusColumnIndex != 0 && !cursor.isNull(contactStatusColumnIndex)) { + statusMessage = cursor.getString(contactStatusColumnIndex); + } + // If there is no status message from the contact, but there was a presence value, then use + // the default status message string + if (statusMessage == null && presence != 0) { + statusMessage = ContactStatusUtil.getStatusString(getContext(), presence); + } + setStatus(statusMessage); + } + + /** + * Shows search snippet. + */ + public void showSnippet(Cursor cursor, int summarySnippetColumnIndex) { + if (cursor.getColumnCount() <= summarySnippetColumnIndex) { + setSnippet(null); + return; + } + String snippet; + String columnContent = cursor.getString(summarySnippetColumnIndex); + + // Do client side snippeting if provider didn't do it + Bundle extras = cursor.getExtras(); + if (extras.getBoolean(ContactsContract.DEFERRED_SNIPPETING)) { + int displayNameIndex = cursor.getColumnIndex(Contacts.DISPLAY_NAME); + + snippet = ContactsContract.snippetize(columnContent, + displayNameIndex < 0 ? null : cursor.getString(displayNameIndex), + extras.getString(ContactsContract.DEFERRED_SNIPPETING_QUERY), + DefaultContactListAdapter.SNIPPET_START_MATCH, + DefaultContactListAdapter.SNIPPET_END_MATCH, + DefaultContactListAdapter.SNIPPET_ELLIPSIS, + DefaultContactListAdapter.SNIPPET_MAX_TOKENS); + } else { + snippet = columnContent; + } + + if (snippet != null) { + int from = 0; + int to = snippet.length(); + int start = snippet.indexOf(DefaultContactListAdapter.SNIPPET_START_MATCH); + if (start == -1) { + snippet = null; + } else { + int firstNl = snippet.lastIndexOf('\n', start); + if (firstNl != -1) { + from = firstNl + 1; + } + int end = snippet.lastIndexOf(DefaultContactListAdapter.SNIPPET_END_MATCH); + if (end != -1) { + int lastNl = snippet.indexOf('\n', end); + if (lastNl != -1) { + to = lastNl; + } + } + + StringBuilder sb = new StringBuilder(); + for (int i = from; i < to; i++) { + char c = snippet.charAt(i); + if (c != DefaultContactListAdapter.SNIPPET_START_MATCH && + c != DefaultContactListAdapter.SNIPPET_END_MATCH) { + sb.append(c); + } + } + snippet = sb.toString(); + } + } + setSnippet(snippet); + } + + /** + * Shows data element (e.g. phone number). + */ + public void showData(Cursor cursor, int dataColumnIndex) { + cursor.copyStringToBuffer(dataColumnIndex, mDataBuffer); + setData(mDataBuffer.data, mDataBuffer.sizeCopied); + } + + public void setActivatedStateSupported(boolean flag) { + this.mActivatedStateSupported = flag; + } + + @Override + public void requestLayout() { + // We will assume that once measured this will not need to resize + // itself, so there is no need to pass the layout request to the parent + // view (ListView). + forceLayout(); + } + + public void setPhotoPosition(PhotoPosition photoPosition) { + mPhotoPosition = photoPosition; + } + + public PhotoPosition getPhotoPosition() { + return mPhotoPosition; + } + + /** + * Specifies left and right margin for selection bounds. See also + * {@link #adjustListItemSelectionBounds(Rect)}. + */ + public void setSelectionBoundsHorizontalMargin(int left, int right) { + mSelectionBoundsMarginLeft = left; + mSelectionBoundsMarginRight = right; + } +} diff --git a/src/com/android/contacts/common/list/ContactListPinnedHeaderView.java b/src/com/android/contacts/common/list/ContactListPinnedHeaderView.java new file mode 100644 index 00000000..9aa9a9b1 --- /dev/null +++ b/src/com/android/contacts/common/list/ContactListPinnedHeaderView.java @@ -0,0 +1,178 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.list; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.graphics.Typeface; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import com.android.contacts.common.R; + +/** + * A custom view for the pinned section header shown at the top of the contact list. + */ +public class ContactListPinnedHeaderView extends ViewGroup { + + protected final Context mContext; + + private final int mHeaderTextColor; + private final int mHeaderTextIndent; + private final int mHeaderTextSize; + private final int mHeaderUnderlineHeight; + private final int mHeaderUnderlineColor; + private final int mPaddingRight; + private final int mPaddingLeft; + private final int mContactsCountTextColor; + private final int mCountViewTextSize; + + private int mHeaderBackgroundHeight; + private TextView mHeaderTextView; + private TextView mCountTextView = null; + private View mHeaderDivider; + + public ContactListPinnedHeaderView(Context context, AttributeSet attrs) { + super(context, attrs); + mContext = context; + + TypedArray a = getContext().obtainStyledAttributes(attrs, R.styleable.ContactListItemView); + + mHeaderTextIndent = a.getDimensionPixelOffset( + R.styleable.ContactListItemView_list_item_header_text_indent, 0); + mHeaderTextColor = a.getColor( + R.styleable.ContactListItemView_list_item_header_text_color, Color.BLACK); + mHeaderTextSize = a.getDimensionPixelSize( + R.styleable.ContactListItemView_list_item_header_text_size, 12); + mHeaderUnderlineHeight = a.getDimensionPixelSize( + R.styleable.ContactListItemView_list_item_header_underline_height, 1); + mHeaderUnderlineColor = a.getColor( + R.styleable.ContactListItemView_list_item_header_underline_color, 0); + mHeaderBackgroundHeight = a.getDimensionPixelSize( + R.styleable.ContactListItemView_list_item_header_height, 30); + mPaddingLeft = a.getDimensionPixelOffset( + R.styleable.ContactListItemView_list_item_padding_left, 0); + mPaddingRight = a.getDimensionPixelOffset( + R.styleable.ContactListItemView_list_item_padding_right, 0); + mContactsCountTextColor = a.getColor( + R.styleable.ContactListItemView_list_item_contacts_count_text_color, Color.BLACK); + mCountViewTextSize = (int)a.getDimensionPixelSize( + R.styleable.ContactListItemView_list_item_contacts_count_text_size, 12); + + a.recycle(); + + mHeaderTextView = new TextView(mContext); + mHeaderTextView.setTextColor(mHeaderTextColor); + mHeaderTextView.setTextSize(TypedValue.COMPLEX_UNIT_PX, mHeaderTextSize); + mHeaderTextView.setTypeface(mHeaderTextView.getTypeface(), Typeface.BOLD); + mHeaderTextView.setGravity(Gravity.CENTER_VERTICAL); + mHeaderTextView.setAllCaps(true); + addView(mHeaderTextView); + mHeaderDivider = new View(mContext); + mHeaderDivider.setBackgroundColor(mHeaderUnderlineColor); + addView(mHeaderDivider); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + + // We will match parent's width and wrap content vertically. + int width = resolveSize(0, widthMeasureSpec); + + mHeaderTextView.measure( + MeasureSpec.makeMeasureSpec(width, MeasureSpec.AT_MOST), + MeasureSpec.makeMeasureSpec(mHeaderBackgroundHeight, MeasureSpec.EXACTLY)); + if (isViewMeasurable(mCountTextView)) { + mCountTextView.measure( + MeasureSpec.makeMeasureSpec(width, MeasureSpec.AT_MOST), + MeasureSpec.makeMeasureSpec(mHeaderBackgroundHeight, MeasureSpec.EXACTLY)); + } + + setMeasuredDimension(width, mHeaderBackgroundHeight + mHeaderUnderlineHeight); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + int width = right - left; + + // Take into account left and right padding when laying out the below views. + mHeaderTextView.layout(mHeaderTextIndent + mPaddingLeft, + 0, + mHeaderTextView.getMeasuredWidth() + mHeaderTextIndent + mPaddingLeft, + mHeaderBackgroundHeight); + + if (isViewMeasurable(mCountTextView)) { + mCountTextView.layout(width - mPaddingRight - mCountTextView.getMeasuredWidth(), + 0, + width - mPaddingRight, + mHeaderBackgroundHeight); + } + + mHeaderDivider.layout(mPaddingLeft, + mHeaderBackgroundHeight, + width - mPaddingRight, + mHeaderBackgroundHeight + mHeaderUnderlineHeight); + } + + /** + * Sets section header or makes it invisible if the title is null. + */ + public void setSectionHeader(String title) { + if (!TextUtils.isEmpty(title)) { + mHeaderTextView.setText(title); + mHeaderTextView.setVisibility(View.VISIBLE); + mHeaderDivider.setVisibility(View.VISIBLE); + } else { + mHeaderTextView.setVisibility(View.GONE); + mHeaderDivider.setVisibility(View.GONE); + } + } + + @Override + public void requestLayout() { + // We will assume that once measured this will not need to resize + // itself, so there is no need to pass the layout request to the parent + // view (ListView). + forceLayout(); + } + + public void setCountView(String count) { + if (mCountTextView == null) { + mCountTextView = new TextView(mContext); + mCountTextView.setTextColor(mContactsCountTextColor); + mCountTextView.setTextSize(TypedValue.COMPLEX_UNIT_PX, mCountViewTextSize); + mCountTextView.setGravity(Gravity.CENTER_VERTICAL); + addView(mCountTextView); + } + mCountTextView.setText(count); + if (count == null || count.isEmpty()) { + mCountTextView.setVisibility(View.GONE); + } else { + mCountTextView.setVisibility(View.VISIBLE); + } + } + + private boolean isViewMeasurable(View view) { + return (view != null && view.getVisibility() == View.VISIBLE); + } +} diff --git a/src/com/android/contacts/common/list/ContactsSectionIndexer.java b/src/com/android/contacts/common/list/ContactsSectionIndexer.java new file mode 100644 index 00000000..8d1c9e1d --- /dev/null +++ b/src/com/android/contacts/common/list/ContactsSectionIndexer.java @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.list; + +import android.text.TextUtils; +import android.widget.SectionIndexer; + +import java.util.Arrays; + +/** + * A section indexer that is configured with precomputed section titles and + * their respective counts. + */ +public class ContactsSectionIndexer implements SectionIndexer { + + private String[] mSections; + private int[] mPositions; + private int mCount; + private static final String BLANK_HEADER_STRING = " "; + + /** + * Constructor. + * + * @param sections a non-null array + * @param counts a non-null array of the same size as <code>sections</code> + */ + public ContactsSectionIndexer(String[] sections, int[] counts) { + if (sections == null || counts == null) { + throw new NullPointerException(); + } + + if (sections.length != counts.length) { + throw new IllegalArgumentException( + "The sections and counts arrays must have the same length"); + } + + // TODO process sections/counts based on current locale and/or specific section titles + + this.mSections = sections; + mPositions = new int[counts.length]; + int position = 0; + for (int i = 0; i < counts.length; i++) { + if (TextUtils.isEmpty(mSections[i])) { + mSections[i] = BLANK_HEADER_STRING; + } else if (!mSections[i].equals(BLANK_HEADER_STRING)) { + mSections[i] = mSections[i].trim(); + } + + mPositions[i] = position; + position += counts[i]; + } + mCount = position; + } + + public Object[] getSections() { + return mSections; + } + + public int getPositionForSection(int section) { + if (section < 0 || section >= mSections.length) { + return -1; + } + + return mPositions[section]; + } + + public int getSectionForPosition(int position) { + if (position < 0 || position >= mCount) { + return -1; + } + + int index = Arrays.binarySearch(mPositions, position); + + /* + * Consider this example: section positions are 0, 3, 5; the supplied + * position is 4. The section corresponding to position 4 starts at + * position 3, so the expected return value is 1. Binary search will not + * find 4 in the array and thus will return -insertPosition-1, i.e. -3. + * To get from that number to the expected value of 1 we need to negate + * and subtract 2. + */ + return index >= 0 ? index : -index - 2; + } + + public void setProfileHeader(String header) { + if (mSections != null) { + // Don't do anything if the header is already set properly. + if (mSections.length > 0 && header.equals(mSections[0])) { + return; + } + + // Since the section indexer isn't aware of the profile at the top, we need to add a + // special section at the top for it and shift everything else down. + String[] tempSections = new String[mSections.length + 1]; + int[] tempPositions = new int[mPositions.length + 1]; + tempSections[0] = header; + tempPositions[0] = 0; + for (int i = 1; i <= mPositions.length; i++) { + tempSections[i] = mSections[i - 1]; + tempPositions[i] = mPositions[i - 1] + 1; + } + mSections = tempSections; + mPositions = tempPositions; + mCount++; + } + } +} diff --git a/src/com/android/contacts/common/list/DefaultContactListAdapter.java b/src/com/android/contacts/common/list/DefaultContactListAdapter.java new file mode 100644 index 00000000..6ad9e8b0 --- /dev/null +++ b/src/com/android/contacts/common/list/DefaultContactListAdapter.java @@ -0,0 +1,222 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.contacts.common.list; + +import android.content.ContentUris; +import android.content.Context; +import android.content.CursorLoader; +import android.content.SharedPreferences; +import android.database.Cursor; +import android.net.Uri; +import android.net.Uri.Builder; +import android.preference.PreferenceManager; +import android.provider.ContactsContract; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.Directory; +import android.provider.ContactsContract.SearchSnippetColumns; +import android.text.TextUtils; +import android.view.View; + +import com.android.contacts.common.preference.ContactsPreferences; + +import java.util.ArrayList; +import java.util.List; + +/** + * A cursor adapter for the {@link ContactsContract.Contacts#CONTENT_TYPE} content type. + */ +public class DefaultContactListAdapter extends ContactListAdapter { + + public static final char SNIPPET_START_MATCH = '\u0001'; + public static final char SNIPPET_END_MATCH = '\u0001'; + public static final String SNIPPET_ELLIPSIS = "\u2026"; + public static final int SNIPPET_MAX_TOKENS = 5; + + public static final String SNIPPET_ARGS = SNIPPET_START_MATCH + "," + SNIPPET_END_MATCH + "," + + SNIPPET_ELLIPSIS + "," + SNIPPET_MAX_TOKENS; + + public DefaultContactListAdapter(Context context) { + super(context); + } + + @Override + public void configureLoader(CursorLoader loader, long directoryId) { + if (loader instanceof ProfileAndContactsLoader) { + ((ProfileAndContactsLoader) loader).setLoadProfile(shouldIncludeProfile()); + } + + ContactListFilter filter = getFilter(); + if (isSearchMode()) { + String query = getQueryString(); + if (query == null) { + query = ""; + } + query = query.trim(); + if (TextUtils.isEmpty(query)) { + // Regardless of the directory, we don't want anything returned, + // so let's just send a "nothing" query to the local directory. + loader.setUri(Contacts.CONTENT_URI); + loader.setProjection(getProjection(false)); + loader.setSelection("0"); + } else { + Builder builder = Contacts.CONTENT_FILTER_URI.buildUpon(); + builder.appendPath(query); // Builder will encode the query + builder.appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY, + String.valueOf(directoryId)); + if (directoryId != Directory.DEFAULT && directoryId != Directory.LOCAL_INVISIBLE) { + builder.appendQueryParameter(ContactsContract.LIMIT_PARAM_KEY, + String.valueOf(getDirectoryResultLimit())); + } + builder.appendQueryParameter(SearchSnippetColumns.SNIPPET_ARGS_PARAM_KEY, + SNIPPET_ARGS); + builder.appendQueryParameter(SearchSnippetColumns.DEFERRED_SNIPPETING_KEY,"1"); + loader.setUri(builder.build()); + loader.setProjection(getProjection(true)); + } + } else { + configureUri(loader, directoryId, filter); + loader.setProjection(getProjection(false)); + configureSelection(loader, directoryId, filter); + } + + String sortOrder; + if (getSortOrder() == ContactsContract.Preferences.SORT_ORDER_PRIMARY) { + sortOrder = Contacts.SORT_KEY_PRIMARY; + } else { + sortOrder = Contacts.SORT_KEY_ALTERNATIVE; + } + + loader.setSortOrder(sortOrder); + } + + protected void configureUri(CursorLoader loader, long directoryId, ContactListFilter filter) { + Uri uri = Contacts.CONTENT_URI; + if (filter != null && filter.filterType == ContactListFilter.FILTER_TYPE_SINGLE_CONTACT) { + String lookupKey = getSelectedContactLookupKey(); + if (lookupKey != null) { + uri = Uri.withAppendedPath(Contacts.CONTENT_LOOKUP_URI, lookupKey); + } else { + uri = ContentUris.withAppendedId(Contacts.CONTENT_URI, getSelectedContactId()); + } + } + + if (directoryId == Directory.DEFAULT && isSectionHeaderDisplayEnabled()) { + uri = ContactListAdapter.buildSectionIndexerUri(uri); + } + + // The "All accounts" filter is the same as the entire contents of Directory.DEFAULT + if (filter != null + && filter.filterType != ContactListFilter.FILTER_TYPE_CUSTOM + && filter.filterType != ContactListFilter.FILTER_TYPE_SINGLE_CONTACT) { + final Uri.Builder builder = uri.buildUpon(); + builder.appendQueryParameter( + ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(Directory.DEFAULT)); + if (filter.filterType == ContactListFilter.FILTER_TYPE_ACCOUNT) { + filter.addAccountQueryParameterToUrl(builder); + } + uri = builder.build(); + } + + loader.setUri(uri); + } + + private void configureSelection( + CursorLoader loader, long directoryId, ContactListFilter filter) { + if (filter == null) { + return; + } + + if (directoryId != Directory.DEFAULT) { + return; + } + + StringBuilder selection = new StringBuilder(); + List<String> selectionArgs = new ArrayList<String>(); + + switch (filter.filterType) { + case ContactListFilter.FILTER_TYPE_ALL_ACCOUNTS: { + // We have already added directory=0 to the URI, which takes care of this + // filter + break; + } + case ContactListFilter.FILTER_TYPE_SINGLE_CONTACT: { + // We have already added the lookup key to the URI, which takes care of this + // filter + break; + } + case ContactListFilter.FILTER_TYPE_STARRED: { + selection.append(Contacts.STARRED + "!=0"); + break; + } + case ContactListFilter.FILTER_TYPE_WITH_PHONE_NUMBERS_ONLY: { + selection.append(Contacts.HAS_PHONE_NUMBER + "=1"); + break; + } + case ContactListFilter.FILTER_TYPE_CUSTOM: { + selection.append(Contacts.IN_VISIBLE_GROUP + "=1"); + if (isCustomFilterForPhoneNumbersOnly()) { + selection.append(" AND " + Contacts.HAS_PHONE_NUMBER + "=1"); + } + break; + } + case ContactListFilter.FILTER_TYPE_ACCOUNT: { + // We use query parameters for account filter, so no selection to add here. + break; + } + } + loader.setSelection(selection.toString()); + loader.setSelectionArgs(selectionArgs.toArray(new String[0])); + } + + @Override + protected void bindView(View itemView, int partition, Cursor cursor, int position) { + final ContactListItemView view = (ContactListItemView)itemView; + + view.setHighlightedPrefix(isSearchMode() ? getUpperCaseQueryString() : null); + + if (isSelectionVisible()) { + view.setActivated(isSelectedContact(partition, cursor)); + } + + bindSectionHeaderAndDivider(view, position, cursor); + + if (isQuickContactEnabled()) { + bindQuickContact(view, partition, cursor, ContactQuery.CONTACT_PHOTO_ID, + ContactQuery.CONTACT_PHOTO_URI, ContactQuery.CONTACT_ID, + ContactQuery.CONTACT_LOOKUP_KEY); + } else { + if (getDisplayPhotos()) { + bindPhoto(view, partition, cursor); + } + } + + bindName(view, cursor); + bindPresenceAndStatusMessage(view, cursor); + + if (isSearchMode()) { + bindSearchSnippet(view, cursor); + } else { + view.setSnippet(null); + } + } + + private boolean isCustomFilterForPhoneNumbersOnly() { + // TODO: this flag should not be stored in shared prefs. It needs to be in the db. + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getContext()); + return prefs.getBoolean(ContactsPreferences.PREF_DISPLAY_ONLY_PHONES, + ContactsPreferences.PREF_DISPLAY_ONLY_PHONES_DEFAULT); + } +} diff --git a/src/com/android/contacts/common/list/DirectoryListLoader.java b/src/com/android/contacts/common/list/DirectoryListLoader.java new file mode 100644 index 00000000..be9a8e9b --- /dev/null +++ b/src/com/android/contacts/common/list/DirectoryListLoader.java @@ -0,0 +1,197 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.contacts.common.list; + +import android.content.AsyncTaskLoader; +import android.content.Context; +import android.content.pm.PackageManager; +import android.database.ContentObserver; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.os.Handler; +import android.provider.ContactsContract.Directory; +import android.text.TextUtils; +import android.util.Log; + +import com.android.contacts.common.R; + +/** + * A specialized loader for the list of directories, see {@link Directory}. + */ +public class DirectoryListLoader extends AsyncTaskLoader<Cursor> { + + private static final String TAG = "ContactEntryListAdapter"; + + public static final int SEARCH_MODE_NONE = 0; + public static final int SEARCH_MODE_DEFAULT = 1; + public static final int SEARCH_MODE_CONTACT_SHORTCUT = 2; + public static final int SEARCH_MODE_DATA_SHORTCUT = 3; + + private static final class DirectoryQuery { + public static final Uri URI = Directory.CONTENT_URI; + public static final String ORDER_BY = Directory._ID; + + public static final String[] PROJECTION = { + Directory._ID, + Directory.PACKAGE_NAME, + Directory.TYPE_RESOURCE_ID, + Directory.DISPLAY_NAME, + Directory.PHOTO_SUPPORT, + }; + + public static final int ID = 0; + public static final int PACKAGE_NAME = 1; + public static final int TYPE_RESOURCE_ID = 2; + public static final int DISPLAY_NAME = 3; + public static final int PHOTO_SUPPORT = 4; + } + + // This is a virtual column created for a MatrixCursor. + public static final String DIRECTORY_TYPE = "directoryType"; + + private static final String[] RESULT_PROJECTION = { + Directory._ID, + DIRECTORY_TYPE, + Directory.DISPLAY_NAME, + Directory.PHOTO_SUPPORT, + }; + + private final ContentObserver mObserver = new ContentObserver(new Handler()) { + @Override + public void onChange(boolean selfChange) { + forceLoad(); + } + }; + + private int mDirectorySearchMode; + private boolean mLocalInvisibleDirectoryEnabled; + + private MatrixCursor mDefaultDirectoryList; + + public DirectoryListLoader(Context context) { + super(context); + } + + public void setDirectorySearchMode(int mode) { + mDirectorySearchMode = mode; + } + + /** + * A flag that indicates whether the {@link Directory#LOCAL_INVISIBLE} directory should + * be included in the results. + */ + public void setLocalInvisibleDirectoryEnabled(boolean flag) { + this.mLocalInvisibleDirectoryEnabled = flag; + } + + @Override + protected void onStartLoading() { + getContext().getContentResolver(). + registerContentObserver(Directory.CONTENT_URI, false, mObserver); + forceLoad(); + } + + @Override + protected void onStopLoading() { + getContext().getContentResolver().unregisterContentObserver(mObserver); + } + + @Override + public Cursor loadInBackground() { + if (mDirectorySearchMode == SEARCH_MODE_NONE) { + return getDefaultDirectories(); + } + + MatrixCursor result = new MatrixCursor(RESULT_PROJECTION); + Context context = getContext(); + PackageManager pm = context.getPackageManager(); + String selection; + switch (mDirectorySearchMode) { + case SEARCH_MODE_DEFAULT: + selection = mLocalInvisibleDirectoryEnabled ? null + : (Directory._ID + "!=" + Directory.LOCAL_INVISIBLE); + break; + + case SEARCH_MODE_CONTACT_SHORTCUT: + selection = Directory.SHORTCUT_SUPPORT + "=" + Directory.SHORTCUT_SUPPORT_FULL + + (mLocalInvisibleDirectoryEnabled ? "" + : (" AND " + Directory._ID + "!=" + Directory.LOCAL_INVISIBLE)); + break; + + case SEARCH_MODE_DATA_SHORTCUT: + selection = Directory.SHORTCUT_SUPPORT + " IN (" + + Directory.SHORTCUT_SUPPORT_FULL + ", " + + Directory.SHORTCUT_SUPPORT_DATA_ITEMS_ONLY + ")" + + (mLocalInvisibleDirectoryEnabled ? "" + : (" AND " + Directory._ID + "!=" + Directory.LOCAL_INVISIBLE)); + break; + + default: + throw new RuntimeException( + "Unsupported directory search mode: " + mDirectorySearchMode); + } + + Cursor cursor = context.getContentResolver().query(DirectoryQuery.URI, + DirectoryQuery.PROJECTION, selection, null, DirectoryQuery.ORDER_BY); + try { + while(cursor.moveToNext()) { + long directoryId = cursor.getLong(DirectoryQuery.ID); + String directoryType = null; + + String packageName = cursor.getString(DirectoryQuery.PACKAGE_NAME); + int typeResourceId = cursor.getInt(DirectoryQuery.TYPE_RESOURCE_ID); + if (!TextUtils.isEmpty(packageName) && typeResourceId != 0) { + try { + directoryType = pm.getResourcesForApplication(packageName) + .getString(typeResourceId); + } catch (Exception e) { + Log.e(TAG, "Cannot obtain directory type from package: " + packageName); + } + } + String displayName = cursor.getString(DirectoryQuery.DISPLAY_NAME); + int photoSupport = cursor.getInt(DirectoryQuery.PHOTO_SUPPORT); + result.addRow(new Object[]{directoryId, directoryType, displayName, photoSupport}); + } + } finally { + cursor.close(); + } + + return result; + } + + private Cursor getDefaultDirectories() { + if (mDefaultDirectoryList == null) { + mDefaultDirectoryList = new MatrixCursor(RESULT_PROJECTION); + mDefaultDirectoryList.addRow(new Object[] { + Directory.DEFAULT, + getContext().getString(R.string.contactsList), + null + }); + mDefaultDirectoryList.addRow(new Object[] { + Directory.LOCAL_INVISIBLE, + getContext().getString(R.string.local_invisible_directory), + null + }); + } + return mDefaultDirectoryList; + } + + @Override + protected void onReset() { + stopLoading(); + } +} diff --git a/src/com/android/contacts/common/list/DirectoryPartition.java b/src/com/android/contacts/common/list/DirectoryPartition.java new file mode 100644 index 00000000..022d1e60 --- /dev/null +++ b/src/com/android/contacts/common/list/DirectoryPartition.java @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.contacts.common.list; + +import android.provider.ContactsContract.Directory; + +import com.android.common.widget.CompositeCursorAdapter; + +/** + * Model object for a {@link Directory} row. + */ +public final class DirectoryPartition extends CompositeCursorAdapter.Partition { + + public static final int STATUS_NOT_LOADED = 0; + public static final int STATUS_LOADING = 1; + public static final int STATUS_LOADED = 2; + + private long mDirectoryId; + private String mDirectoryType; + private String mDisplayName; + private int mStatus; + private boolean mPriorityDirectory; + private boolean mPhotoSupported; + + public DirectoryPartition(boolean showIfEmpty, boolean hasHeader) { + super(showIfEmpty, hasHeader); + } + + /** + * Directory ID, see {@link Directory}. + */ + public long getDirectoryId() { + return mDirectoryId; + } + + public void setDirectoryId(long directoryId) { + this.mDirectoryId = directoryId; + } + + /** + * Directory type resolved from {@link Directory#PACKAGE_NAME} and + * {@link Directory#TYPE_RESOURCE_ID}; + */ + public String getDirectoryType() { + return mDirectoryType; + } + + public void setDirectoryType(String directoryType) { + this.mDirectoryType = directoryType; + } + + /** + * See {@link Directory#DISPLAY_NAME}. + */ + public String getDisplayName() { + return mDisplayName; + } + + public void setDisplayName(String displayName) { + this.mDisplayName = displayName; + } + + public int getStatus() { + return mStatus; + } + + public void setStatus(int status) { + mStatus = status; + } + + public boolean isLoading() { + return mStatus == STATUS_NOT_LOADED || mStatus == STATUS_LOADING; + } + + /** + * Returns true if this directory should be loaded before non-priority directories. + */ + public boolean isPriorityDirectory() { + return mPriorityDirectory; + } + + public void setPriorityDirectory(boolean priorityDirectory) { + mPriorityDirectory = priorityDirectory; + } + + /** + * Returns true if this directory supports photos. + */ + public boolean isPhotoSupported() { + return mPhotoSupported; + } + + public void setPhotoSupported(boolean flag) { + this.mPhotoSupported = flag; + } +} diff --git a/src/com/android/contacts/common/list/IndexerListAdapter.java b/src/com/android/contacts/common/list/IndexerListAdapter.java new file mode 100644 index 00000000..830ea81f --- /dev/null +++ b/src/com/android/contacts/common/list/IndexerListAdapter.java @@ -0,0 +1,235 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.contacts.common.list; + +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ListView; +import android.widget.SectionIndexer; + +/** + * A list adapter that supports section indexer and a pinned header. + */ +public abstract class IndexerListAdapter extends PinnedHeaderListAdapter implements SectionIndexer { + + protected Context mContext; + private SectionIndexer mIndexer; + private int mIndexedPartition = 0; + private boolean mSectionHeaderDisplayEnabled; + private View mHeader; + + /** + * An item view is displayed differently depending on whether it is placed + * at the beginning, middle or end of a section. It also needs to know the + * section header when it is at the beginning of a section. This object + * captures all this configuration. + */ + public static final class Placement { + private int position = ListView.INVALID_POSITION; + public boolean firstInSection; + public boolean lastInSection; + public String sectionHeader; + + public void invalidate() { + position = ListView.INVALID_POSITION; + } + } + + private Placement mPlacementCache = new Placement(); + + /** + * Constructor. + */ + public IndexerListAdapter(Context context) { + super(context); + mContext = context; + } + + /** + * Creates a section header view that will be pinned at the top of the list + * as the user scrolls. + */ + protected abstract View createPinnedSectionHeaderView(Context context, ViewGroup parent); + + /** + * Sets the title in the pinned header as the user scrolls. + */ + protected abstract void setPinnedSectionTitle(View pinnedHeaderView, String title); + + /** + * Sets the contacts count in the pinned header. + */ + protected abstract void setPinnedHeaderContactsCount(View header); + + /** + * clears the contacts count in the pinned header and makes the view invisible. + */ + protected abstract void clearPinnedHeaderContactsCount(View header); + + public boolean isSectionHeaderDisplayEnabled() { + return mSectionHeaderDisplayEnabled; + } + + public void setSectionHeaderDisplayEnabled(boolean flag) { + this.mSectionHeaderDisplayEnabled = flag; + } + + public int getIndexedPartition() { + return mIndexedPartition; + } + + public void setIndexedPartition(int partition) { + this.mIndexedPartition = partition; + } + + public SectionIndexer getIndexer() { + return mIndexer; + } + + public void setIndexer(SectionIndexer indexer) { + mIndexer = indexer; + mPlacementCache.invalidate(); + } + + public Object[] getSections() { + if (mIndexer == null) { + return new String[] { " " }; + } else { + return mIndexer.getSections(); + } + } + + /** + * @return relative position of the section in the indexed partition + */ + public int getPositionForSection(int sectionIndex) { + if (mIndexer == null) { + return -1; + } + + return mIndexer.getPositionForSection(sectionIndex); + } + + /** + * @param position relative position in the indexed partition + */ + public int getSectionForPosition(int position) { + if (mIndexer == null) { + return -1; + } + + return mIndexer.getSectionForPosition(position); + } + + @Override + public int getPinnedHeaderCount() { + if (isSectionHeaderDisplayEnabled()) { + return super.getPinnedHeaderCount() + 1; + } else { + return super.getPinnedHeaderCount(); + } + } + + @Override + public View getPinnedHeaderView(int viewIndex, View convertView, ViewGroup parent) { + if (isSectionHeaderDisplayEnabled() && viewIndex == getPinnedHeaderCount() - 1) { + if (mHeader == null) { + mHeader = createPinnedSectionHeaderView(mContext, parent); + } + return mHeader; + } else { + return super.getPinnedHeaderView(viewIndex, convertView, parent); + } + } + + @Override + public void configurePinnedHeaders(PinnedHeaderListView listView) { + super.configurePinnedHeaders(listView); + + if (!isSectionHeaderDisplayEnabled()) { + return; + } + + int index = getPinnedHeaderCount() - 1; + if (mIndexer == null || getCount() == 0) { + listView.setHeaderInvisible(index, false); + } else { + int listPosition = listView.getPositionAt(listView.getTotalTopPinnedHeaderHeight()); + int position = listPosition - listView.getHeaderViewsCount(); + + int section = -1; + int partition = getPartitionForPosition(position); + if (partition == mIndexedPartition) { + int offset = getOffsetInPartition(position); + if (offset != -1) { + section = getSectionForPosition(offset); + } + } + + if (section == -1) { + listView.setHeaderInvisible(index, false); + } else { + setPinnedSectionTitle(mHeader, (String)mIndexer.getSections()[section]); + if (section == 0) { + setPinnedHeaderContactsCount(mHeader); + } else { + clearPinnedHeaderContactsCount(mHeader); + } + // Compute the item position where the current partition begins + int partitionStart = getPositionForPartition(mIndexedPartition); + if (hasHeader(mIndexedPartition)) { + partitionStart++; + } + + // Compute the item position where the next section begins + int nextSectionPosition = partitionStart + getPositionForSection(section + 1); + boolean isLastInSection = position == nextSectionPosition - 1; + listView.setFadingHeader(index, listPosition, isLastInSection); + } + } + } + + /** + * Computes the item's placement within its section and populates the {@code placement} + * object accordingly. Please note that the returned object is volatile and should be + * copied if the result needs to be used later. + */ + public Placement getItemPlacementInSection(int position) { + if (mPlacementCache.position == position) { + return mPlacementCache; + } + + mPlacementCache.position = position; + if (isSectionHeaderDisplayEnabled()) { + int section = getSectionForPosition(position); + if (section != -1 && getPositionForSection(section) == position) { + mPlacementCache.firstInSection = true; + mPlacementCache.sectionHeader = (String)getSections()[section]; + } else { + mPlacementCache.firstInSection = false; + mPlacementCache.sectionHeader = null; + } + + mPlacementCache.lastInSection = (getPositionForSection(section + 1) - 1 == position); + } else { + mPlacementCache.firstInSection = false; + mPlacementCache.lastInSection = false; + mPlacementCache.sectionHeader = null; + } + return mPlacementCache; + } +} diff --git a/src/com/android/contacts/common/list/PinnedHeaderListAdapter.java b/src/com/android/contacts/common/list/PinnedHeaderListAdapter.java new file mode 100644 index 00000000..9591092a --- /dev/null +++ b/src/com/android/contacts/common/list/PinnedHeaderListAdapter.java @@ -0,0 +1,171 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.contacts.common.list; + +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; + +import com.android.common.widget.CompositeCursorAdapter; + +/** + * A subclass of {@link CompositeCursorAdapter} that manages pinned partition headers. + */ +public abstract class PinnedHeaderListAdapter extends CompositeCursorAdapter + implements PinnedHeaderListView.PinnedHeaderAdapter { + + public static final int PARTITION_HEADER_TYPE = 0; + + private boolean mPinnedPartitionHeadersEnabled; + private boolean mHeaderVisibility[]; + + public PinnedHeaderListAdapter(Context context) { + super(context); + } + + public PinnedHeaderListAdapter(Context context, int initialCapacity) { + super(context, initialCapacity); + } + + public boolean getPinnedPartitionHeadersEnabled() { + return mPinnedPartitionHeadersEnabled; + } + + public void setPinnedPartitionHeadersEnabled(boolean flag) { + this.mPinnedPartitionHeadersEnabled = flag; + } + + @Override + public int getPinnedHeaderCount() { + if (mPinnedPartitionHeadersEnabled) { + return getPartitionCount(); + } else { + return 0; + } + } + + protected boolean isPinnedPartitionHeaderVisible(int partition) { + return mPinnedPartitionHeadersEnabled && hasHeader(partition) + && !isPartitionEmpty(partition); + } + + /** + * The default implementation creates the same type of view as a normal + * partition header. + */ + @Override + public View getPinnedHeaderView(int partition, View convertView, ViewGroup parent) { + if (hasHeader(partition)) { + View view = null; + if (convertView != null) { + Integer headerType = (Integer)convertView.getTag(); + if (headerType != null && headerType == PARTITION_HEADER_TYPE) { + view = convertView; + } + } + if (view == null) { + view = newHeaderView(getContext(), partition, null, parent); + view.setTag(PARTITION_HEADER_TYPE); + view.setFocusable(false); + view.setEnabled(false); + } + bindHeaderView(view, partition, getCursor(partition)); + return view; + } else { + return null; + } + } + + @Override + public void configurePinnedHeaders(PinnedHeaderListView listView) { + if (!mPinnedPartitionHeadersEnabled) { + return; + } + + int size = getPartitionCount(); + + // Cache visibility bits, because we will need them several times later on + if (mHeaderVisibility == null || mHeaderVisibility.length != size) { + mHeaderVisibility = new boolean[size]; + } + for (int i = 0; i < size; i++) { + boolean visible = isPinnedPartitionHeaderVisible(i); + mHeaderVisibility[i] = visible; + if (!visible) { + listView.setHeaderInvisible(i, true); + } + } + + int headerViewsCount = listView.getHeaderViewsCount(); + + // Starting at the top, find and pin headers for partitions preceding the visible one(s) + int maxTopHeader = -1; + int topHeaderHeight = 0; + for (int i = 0; i < size; i++) { + if (mHeaderVisibility[i]) { + int position = listView.getPositionAt(topHeaderHeight) - headerViewsCount; + int partition = getPartitionForPosition(position); + if (i > partition) { + break; + } + + listView.setHeaderPinnedAtTop(i, topHeaderHeight, false); + topHeaderHeight += listView.getPinnedHeaderHeight(i); + maxTopHeader = i; + } + } + + // Starting at the bottom, find and pin headers for partitions following the visible one(s) + int maxBottomHeader = size; + int bottomHeaderHeight = 0; + int listHeight = listView.getHeight(); + for (int i = size; --i > maxTopHeader;) { + if (mHeaderVisibility[i]) { + int position = listView.getPositionAt(listHeight - bottomHeaderHeight) + - headerViewsCount; + if (position < 0) { + break; + } + + int partition = getPartitionForPosition(position - 1); + if (partition == -1 || i <= partition) { + break; + } + + int height = listView.getPinnedHeaderHeight(i); + bottomHeaderHeight += height; + // Animate the header only if the partition is completely invisible below + // the bottom of the view + int firstPositionForPartition = getPositionForPartition(i); + boolean animate = position < firstPositionForPartition; + listView.setHeaderPinnedAtBottom(i, listHeight - bottomHeaderHeight, animate); + maxBottomHeader = i; + } + } + + // Headers in between the top-pinned and bottom-pinned should be hidden + for (int i = maxTopHeader + 1; i < maxBottomHeader; i++) { + if (mHeaderVisibility[i]) { + listView.setHeaderInvisible(i, isPartitionEmpty(i)); + } + } + } + + @Override + public int getScrollPositionForHeader(int viewIndex) { + return getPositionForPartition(viewIndex); + } +} diff --git a/src/com/android/contacts/common/list/PinnedHeaderListView.java b/src/com/android/contacts/common/list/PinnedHeaderListView.java new file mode 100644 index 00000000..d006f4b7 --- /dev/null +++ b/src/com/android/contacts/common/list/PinnedHeaderListView.java @@ -0,0 +1,523 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.list; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.graphics.RectF; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AbsListView; +import android.widget.AbsListView.OnScrollListener; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemSelectedListener; +import android.widget.ListAdapter; + +/** + * A ListView that maintains a header pinned at the top of the list. The + * pinned header can be pushed up and dissolved as needed. + */ +public class PinnedHeaderListView extends AutoScrollListView + implements OnScrollListener, OnItemSelectedListener { + + /** + * Adapter interface. The list adapter must implement this interface. + */ + public interface PinnedHeaderAdapter { + + /** + * Returns the overall number of pinned headers, visible or not. + */ + int getPinnedHeaderCount(); + + /** + * Creates or updates the pinned header view. + */ + View getPinnedHeaderView(int viewIndex, View convertView, ViewGroup parent); + + /** + * Configures the pinned headers to match the visible list items. The + * adapter should call {@link PinnedHeaderListView#setHeaderPinnedAtTop}, + * {@link PinnedHeaderListView#setHeaderPinnedAtBottom}, + * {@link PinnedHeaderListView#setFadingHeader} or + * {@link PinnedHeaderListView#setHeaderInvisible}, for each header that + * needs to change its position or visibility. + */ + void configurePinnedHeaders(PinnedHeaderListView listView); + + /** + * Returns the list position to scroll to if the pinned header is touched. + * Return -1 if the list does not need to be scrolled. + */ + int getScrollPositionForHeader(int viewIndex); + } + + private static final int MAX_ALPHA = 255; + private static final int TOP = 0; + private static final int BOTTOM = 1; + private static final int FADING = 2; + + private static final int DEFAULT_ANIMATION_DURATION = 20; + + private static final class PinnedHeader { + View view; + boolean visible; + int y; + int height; + int alpha; + int state; + + boolean animating; + boolean targetVisible; + int sourceY; + int targetY; + long targetTime; + } + + private PinnedHeaderAdapter mAdapter; + private int mSize; + private PinnedHeader[] mHeaders; + private RectF mBounds = new RectF(); + private Rect mClipRect = new Rect(); + private OnScrollListener mOnScrollListener; + private OnItemSelectedListener mOnItemSelectedListener; + private int mScrollState; + + private int mAnimationDuration = DEFAULT_ANIMATION_DURATION; + private boolean mAnimating; + private long mAnimationTargetTime; + private int mHeaderPaddingLeft; + private int mHeaderWidth; + + public PinnedHeaderListView(Context context) { + this(context, null, com.android.internal.R.attr.listViewStyle); + } + + public PinnedHeaderListView(Context context, AttributeSet attrs) { + this(context, attrs, com.android.internal.R.attr.listViewStyle); + } + + public PinnedHeaderListView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + super.setOnScrollListener(this); + super.setOnItemSelectedListener(this); + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + super.onLayout(changed, l, t, r, b); + mHeaderPaddingLeft = getPaddingLeft(); + mHeaderWidth = r - l - mHeaderPaddingLeft - getPaddingRight(); + } + + public void setPinnedHeaderAnimationDuration(int duration) { + mAnimationDuration = duration; + } + + @Override + public void setAdapter(ListAdapter adapter) { + mAdapter = (PinnedHeaderAdapter)adapter; + super.setAdapter(adapter); + } + + @Override + public void setOnScrollListener(OnScrollListener onScrollListener) { + mOnScrollListener = onScrollListener; + super.setOnScrollListener(this); + } + + @Override + public void setOnItemSelectedListener(OnItemSelectedListener listener) { + mOnItemSelectedListener = listener; + super.setOnItemSelectedListener(this); + } + + @Override + public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, + int totalItemCount) { + if (mAdapter != null) { + int count = mAdapter.getPinnedHeaderCount(); + if (count != mSize) { + mSize = count; + if (mHeaders == null) { + mHeaders = new PinnedHeader[mSize]; + } else if (mHeaders.length < mSize) { + PinnedHeader[] headers = mHeaders; + mHeaders = new PinnedHeader[mSize]; + System.arraycopy(headers, 0, mHeaders, 0, headers.length); + } + } + + for (int i = 0; i < mSize; i++) { + if (mHeaders[i] == null) { + mHeaders[i] = new PinnedHeader(); + } + mHeaders[i].view = mAdapter.getPinnedHeaderView(i, mHeaders[i].view, this); + } + + mAnimationTargetTime = System.currentTimeMillis() + mAnimationDuration; + mAdapter.configurePinnedHeaders(this); + invalidateIfAnimating(); + + } + if (mOnScrollListener != null) { + mOnScrollListener.onScroll(this, firstVisibleItem, visibleItemCount, totalItemCount); + } + } + + @Override + protected float getTopFadingEdgeStrength() { + // Disable vertical fading at the top when the pinned header is present + return mSize > 0 ? 0 : super.getTopFadingEdgeStrength(); + } + + @Override + public void onScrollStateChanged(AbsListView view, int scrollState) { + mScrollState = scrollState; + if (mOnScrollListener != null) { + mOnScrollListener.onScrollStateChanged(this, scrollState); + } + } + + /** + * Ensures that the selected item is positioned below the top-pinned headers + * and above the bottom-pinned ones. + */ + @Override + public void onItemSelected(AdapterView<?> parent, View view, int position, long id) { + int height = getHeight(); + + int windowTop = 0; + int windowBottom = height; + + for (int i = 0; i < mSize; i++) { + PinnedHeader header = mHeaders[i]; + if (header.visible) { + if (header.state == TOP) { + windowTop = header.y + header.height; + } else if (header.state == BOTTOM) { + windowBottom = header.y; + break; + } + } + } + + View selectedView = getSelectedView(); + if (selectedView != null) { + if (selectedView.getTop() < windowTop) { + setSelectionFromTop(position, windowTop); + } else if (selectedView.getBottom() > windowBottom) { + setSelectionFromTop(position, windowBottom - selectedView.getHeight()); + } + } + + if (mOnItemSelectedListener != null) { + mOnItemSelectedListener.onItemSelected(parent, view, position, id); + } + } + + @Override + public void onNothingSelected(AdapterView<?> parent) { + if (mOnItemSelectedListener != null) { + mOnItemSelectedListener.onNothingSelected(parent); + } + } + + public int getPinnedHeaderHeight(int viewIndex) { + ensurePinnedHeaderLayout(viewIndex); + return mHeaders[viewIndex].view.getHeight(); + } + + /** + * Set header to be pinned at the top. + * + * @param viewIndex index of the header view + * @param y is position of the header in pixels. + * @param animate true if the transition to the new coordinate should be animated + */ + public void setHeaderPinnedAtTop(int viewIndex, int y, boolean animate) { + ensurePinnedHeaderLayout(viewIndex); + PinnedHeader header = mHeaders[viewIndex]; + header.visible = true; + header.y = y; + header.state = TOP; + + // TODO perhaps we should animate at the top as well + header.animating = false; + } + + /** + * Set header to be pinned at the bottom. + * + * @param viewIndex index of the header view + * @param y is position of the header in pixels. + * @param animate true if the transition to the new coordinate should be animated + */ + public void setHeaderPinnedAtBottom(int viewIndex, int y, boolean animate) { + ensurePinnedHeaderLayout(viewIndex); + PinnedHeader header = mHeaders[viewIndex]; + header.state = BOTTOM; + if (header.animating) { + header.targetTime = mAnimationTargetTime; + header.sourceY = header.y; + header.targetY = y; + } else if (animate && (header.y != y || !header.visible)) { + if (header.visible) { + header.sourceY = header.y; + } else { + header.visible = true; + header.sourceY = y + header.height; + } + header.animating = true; + header.targetVisible = true; + header.targetTime = mAnimationTargetTime; + header.targetY = y; + } else { + header.visible = true; + header.y = y; + } + } + + /** + * Set header to be pinned at the top of the first visible item. + * + * @param viewIndex index of the header view + * @param position is position of the header in pixels. + */ + public void setFadingHeader(int viewIndex, int position, boolean fade) { + ensurePinnedHeaderLayout(viewIndex); + + View child = getChildAt(position - getFirstVisiblePosition()); + if (child == null) return; + + PinnedHeader header = mHeaders[viewIndex]; + header.visible = true; + header.state = FADING; + header.alpha = MAX_ALPHA; + header.animating = false; + + int top = getTotalTopPinnedHeaderHeight(); + header.y = top; + if (fade) { + int bottom = child.getBottom() - top; + int headerHeight = header.height; + if (bottom < headerHeight) { + int portion = bottom - headerHeight; + header.alpha = MAX_ALPHA * (headerHeight + portion) / headerHeight; + header.y = top + portion; + } + } + } + + /** + * Makes header invisible. + * + * @param viewIndex index of the header view + * @param animate true if the transition to the new coordinate should be animated + */ + public void setHeaderInvisible(int viewIndex, boolean animate) { + PinnedHeader header = mHeaders[viewIndex]; + if (header.visible && (animate || header.animating) && header.state == BOTTOM) { + header.sourceY = header.y; + if (!header.animating) { + header.visible = true; + header.targetY = getBottom() + header.height; + } + header.animating = true; + header.targetTime = mAnimationTargetTime; + header.targetVisible = false; + } else { + header.visible = false; + } + } + + private void ensurePinnedHeaderLayout(int viewIndex) { + View view = mHeaders[viewIndex].view; + if (view.isLayoutRequested()) { + int widthSpec = View.MeasureSpec.makeMeasureSpec(mHeaderWidth, View.MeasureSpec.EXACTLY); + int heightSpec; + ViewGroup.LayoutParams layoutParams = view.getLayoutParams(); + if (layoutParams != null && layoutParams.height > 0) { + heightSpec = View.MeasureSpec + .makeMeasureSpec(layoutParams.height, View.MeasureSpec.EXACTLY); + } else { + heightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED); + } + view.measure(widthSpec, heightSpec); + int height = view.getMeasuredHeight(); + mHeaders[viewIndex].height = height; + view.layout(0, 0, mHeaderWidth, height); + } + } + + /** + * Returns the sum of heights of headers pinned to the top. + */ + public int getTotalTopPinnedHeaderHeight() { + for (int i = mSize; --i >= 0;) { + PinnedHeader header = mHeaders[i]; + if (header.visible && header.state == TOP) { + return header.y + header.height; + } + } + return 0; + } + + /** + * Returns the list item position at the specified y coordinate. + */ + public int getPositionAt(int y) { + do { + int position = pointToPosition(getPaddingLeft() + 1, y); + if (position != -1) { + return position; + } + // If position == -1, we must have hit a separator. Let's examine + // a nearby pixel + y--; + } while (y > 0); + return 0; + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + if (mScrollState == SCROLL_STATE_IDLE) { + final int y = (int)ev.getY(); + for (int i = mSize; --i >= 0;) { + PinnedHeader header = mHeaders[i]; + if (header.visible && header.y <= y && header.y + header.height > y) { + if (ev.getAction() == MotionEvent.ACTION_DOWN) { + return smoothScrollToPartition(i); + } else { + return true; + } + } + } + } + + return super.onInterceptTouchEvent(ev); + } + + private boolean smoothScrollToPartition(int partition) { + final int position = mAdapter.getScrollPositionForHeader(partition); + if (position == -1) { + return false; + } + + int offset = 0; + for (int i = 0; i < partition; i++) { + PinnedHeader header = mHeaders[i]; + if (header.visible) { + offset += header.height; + } + } + + smoothScrollToPositionFromTop(position + getHeaderViewsCount(), offset); + return true; + } + + private void invalidateIfAnimating() { + mAnimating = false; + for (int i = 0; i < mSize; i++) { + if (mHeaders[i].animating) { + mAnimating = true; + invalidate(); + return; + } + } + } + + @Override + protected void dispatchDraw(Canvas canvas) { + long currentTime = mAnimating ? System.currentTimeMillis() : 0; + + int top = 0; + int bottom = getBottom(); + boolean hasVisibleHeaders = false; + for (int i = 0; i < mSize; i++) { + PinnedHeader header = mHeaders[i]; + if (header.visible) { + hasVisibleHeaders = true; + if (header.state == BOTTOM && header.y < bottom) { + bottom = header.y; + } else if (header.state == TOP || header.state == FADING) { + int newTop = header.y + header.height; + if (newTop > top) { + top = newTop; + } + } + } + } + + if (hasVisibleHeaders) { + canvas.save(); + mClipRect.set(0, top, getWidth(), bottom); + canvas.clipRect(mClipRect); + } + + super.dispatchDraw(canvas); + + if (hasVisibleHeaders) { + canvas.restore(); + + // First draw top headers, then the bottom ones to handle the Z axis correctly + for (int i = mSize; --i >= 0;) { + PinnedHeader header = mHeaders[i]; + if (header.visible && (header.state == TOP || header.state == FADING)) { + drawHeader(canvas, header, currentTime); + } + } + + for (int i = 0; i < mSize; i++) { + PinnedHeader header = mHeaders[i]; + if (header.visible && header.state == BOTTOM) { + drawHeader(canvas, header, currentTime); + } + } + } + + invalidateIfAnimating(); + } + + private void drawHeader(Canvas canvas, PinnedHeader header, long currentTime) { + if (header.animating) { + int timeLeft = (int)(header.targetTime - currentTime); + if (timeLeft <= 0) { + header.y = header.targetY; + header.visible = header.targetVisible; + header.animating = false; + } else { + header.y = header.targetY + (header.sourceY - header.targetY) * timeLeft + / mAnimationDuration; + } + } + if (header.visible) { + View view = header.view; + int saveCount = canvas.save(); + canvas.translate(mHeaderPaddingLeft, header.y); + if (header.state == FADING) { + mBounds.set(0, 0, mHeaderWidth, view.getHeight()); + canvas.saveLayerAlpha(mBounds, header.alpha, Canvas.ALL_SAVE_FLAG); + } + view.draw(canvas); + canvas.restoreToCount(saveCount); + } + } +} diff --git a/src/com/android/contacts/common/list/ProfileAndContactsLoader.java b/src/com/android/contacts/common/list/ProfileAndContactsLoader.java new file mode 100644 index 00000000..9d2bbbb1 --- /dev/null +++ b/src/com/android/contacts/common/list/ProfileAndContactsLoader.java @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.contacts.common.list; + +import android.content.Context; +import android.content.CursorLoader; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.database.MergeCursor; +import android.os.Bundle; +import android.provider.ContactsContract.Profile; + +import com.google.common.collect.Lists; + +import java.util.List; + +/** + * A loader for use in the default contact list, which will also query for the user's profile + * if configured to do so. + */ +public class ProfileAndContactsLoader extends CursorLoader { + + private boolean mLoadProfile; + private String[] mProjection; + + public ProfileAndContactsLoader(Context context) { + super(context); + } + + public void setLoadProfile(boolean flag) { + mLoadProfile = flag; + } + + public void setProjection(String[] projection) { + super.setProjection(projection); + mProjection = projection; + } + + @Override + public Cursor loadInBackground() { + // First load the profile, if enabled. + List<Cursor> cursors = Lists.newArrayList(); + if (mLoadProfile) { + cursors.add(loadProfile()); + } + final Cursor contactsCursor = super.loadInBackground(); + cursors.add(contactsCursor); + return new MergeCursor(cursors.toArray(new Cursor[cursors.size()])) { + @Override + public Bundle getExtras() { + // Need to get the extras from the contacts cursor. + return contactsCursor.getExtras(); + } + }; + } + + /** + * Loads the profile into a MatrixCursor. + */ + private MatrixCursor loadProfile() { + Cursor cursor = getContext().getContentResolver().query(Profile.CONTENT_URI, mProjection, + null, null, null); + try { + MatrixCursor matrix = new MatrixCursor(mProjection); + Object[] row = new Object[mProjection.length]; + while (cursor.moveToNext()) { + for (int i = 0; i < row.length; i++) { + row[i] = cursor.getString(i); + } + matrix.addRow(row); + } + return matrix; + } finally { + cursor.close(); + } + } +} diff --git a/src/com/android/contacts/common/preference/ContactsPreferences.java b/src/com/android/contacts/common/preference/ContactsPreferences.java new file mode 100644 index 00000000..56390fdd --- /dev/null +++ b/src/com/android/contacts/common/preference/ContactsPreferences.java @@ -0,0 +1,160 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.preference; + +import android.content.ContentResolver; +import android.content.Context; +import android.database.ContentObserver; +import android.os.Handler; +import android.provider.ContactsContract; +import android.provider.Settings; +import android.provider.Settings.SettingNotFoundException; + +import com.android.contacts.common.R; + +/** + * Manages user preferences for contacts. + */ +public final class ContactsPreferences extends ContentObserver { + + public static final String PREF_DISPLAY_ONLY_PHONES = "only_phones"; + public static final boolean PREF_DISPLAY_ONLY_PHONES_DEFAULT = false; + + private Context mContext; + private int mSortOrder = -1; + private int mDisplayOrder = -1; + private ChangeListener mListener = null; + private Handler mHandler; + + public ContactsPreferences(Context context) { + super(null); + mContext = context; + mHandler = new Handler(); + } + + public boolean isSortOrderUserChangeable() { + return mContext.getResources().getBoolean(R.bool.config_sort_order_user_changeable); + } + + public int getDefaultSortOrder() { + if (mContext.getResources().getBoolean(R.bool.config_default_sort_order_primary)) { + return ContactsContract.Preferences.SORT_ORDER_PRIMARY; + } else { + return ContactsContract.Preferences.SORT_ORDER_ALTERNATIVE; + } + } + + public int getSortOrder() { + if (!isSortOrderUserChangeable()) { + return getDefaultSortOrder(); + } + + if (mSortOrder == -1) { + try { + mSortOrder = Settings.System.getInt(mContext.getContentResolver(), + ContactsContract.Preferences.SORT_ORDER); + } catch (SettingNotFoundException e) { + mSortOrder = getDefaultSortOrder(); + } + } + return mSortOrder; + } + + public void setSortOrder(int sortOrder) { + mSortOrder = sortOrder; + Settings.System.putInt(mContext.getContentResolver(), + ContactsContract.Preferences.SORT_ORDER, sortOrder); + } + + public boolean isDisplayOrderUserChangeable() { + return mContext.getResources().getBoolean(R.bool.config_display_order_user_changeable); + } + + public int getDefaultDisplayOrder() { + if (mContext.getResources().getBoolean(R.bool.config_default_display_order_primary)) { + return ContactsContract.Preferences.DISPLAY_ORDER_PRIMARY; + } else { + return ContactsContract.Preferences.DISPLAY_ORDER_ALTERNATIVE; + } + } + + public int getDisplayOrder() { + if (!isDisplayOrderUserChangeable()) { + return getDefaultDisplayOrder(); + } + + if (mDisplayOrder == -1) { + try { + mDisplayOrder = Settings.System.getInt(mContext.getContentResolver(), + ContactsContract.Preferences.DISPLAY_ORDER); + } catch (SettingNotFoundException e) { + mDisplayOrder = getDefaultDisplayOrder(); + } + } + return mDisplayOrder; + } + + public void setDisplayOrder(int displayOrder) { + mDisplayOrder = displayOrder; + Settings.System.putInt(mContext.getContentResolver(), + ContactsContract.Preferences.DISPLAY_ORDER, displayOrder); + } + + public void registerChangeListener(ChangeListener listener) { + if (mListener != null) unregisterChangeListener(); + + mListener = listener; + + // Reset preferences to "unknown" because they may have changed while the + // observer was unregistered. + mDisplayOrder = -1; + mSortOrder = -1; + + final ContentResolver contentResolver = mContext.getContentResolver(); + contentResolver.registerContentObserver( + Settings.System.getUriFor( + ContactsContract.Preferences.SORT_ORDER), false, this); + contentResolver.registerContentObserver( + Settings.System.getUriFor( + ContactsContract.Preferences.DISPLAY_ORDER), false, this); + } + + public void unregisterChangeListener() { + if (mListener != null) { + mContext.getContentResolver().unregisterContentObserver(this); + mListener = null; + } + } + + @Override + public void onChange(boolean selfChange) { + // This notification is not sent on the Ui thread. Use the previously created Handler + // to switch to the Ui thread + mHandler.post(new Runnable() { + @Override + public void run() { + mSortOrder = -1; + mDisplayOrder = -1; + if (mListener != null) mListener.onChange(); + } + }); + } + + public interface ChangeListener { + void onChange(); + } +} diff --git a/tests/src/com/android/contacts/common/list/ContactListItemViewTest.java b/tests/src/com/android/contacts/common/list/ContactListItemViewTest.java new file mode 100644 index 00000000..6eb74db3 --- /dev/null +++ b/tests/src/com/android/contacts/common/list/ContactListItemViewTest.java @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.list; + +import android.database.Cursor; +import android.database.MatrixCursor; +import android.provider.ContactsContract; +import android.test.ActivityInstrumentationTestCase2; +import android.test.AndroidTestCase; +import android.test.suitebuilder.annotation.LargeTest; +import android.widget.TextView; + +//import com.android.contacts.activities.PeopleActivity; +import com.android.contacts.common.format.SpannedTestUtils; +//import com.android.contacts.common.test.IntegrationTestUtils; + +/** + * Unit tests for {@link com.android.contacts.common.list.ContactListItemView}. + * + * It uses an {@link ActivityInstrumentationTestCase2} for {@link PeopleActivity} because we need + * to have the style properly setup. + */ +@LargeTest +public class ContactListItemViewTest extends AndroidTestCase { + + //private IntegrationTestUtils mUtils; + + @Override + protected void setUp() throws Exception { + super.setUp(); + // This test requires that the screen be turned on. + //mUtils = new IntegrationTestUtils(getInstrumentation()); + //mUtils.acquireScreenWakeLock(getInstrumentation().getTargetContext()); + } + + @Override + protected void tearDown() throws Exception { + //mUtils.releaseScreenWakeLock(); + super.tearDown(); + } + + public void testShowDisplayName_Simple() { + Cursor cursor = createCursor("John Doe", "Doe John"); + ContactListItemView view = createView(); + + view.showDisplayName(cursor, 0, ContactsContract.Preferences.DISPLAY_ORDER_PRIMARY); + + assertEquals(view.getNameTextView().getText().toString(), "John Doe"); + } + + public void testShowDisplayName_Unknown() { + Cursor cursor = createCursor("", ""); + ContactListItemView view = createView(); + + view.setUnknownNameText("unknown"); + view.showDisplayName(cursor, 0, ContactsContract.Preferences.DISPLAY_ORDER_PRIMARY); + + assertEquals(view.getNameTextView().getText().toString(), "unknown"); + } + + public void testShowDisplayName_WithPrefix() { + Cursor cursor = createCursor("John Doe", "Doe John"); + ContactListItemView view = createView(); + + view.setHighlightedPrefix("DOE".toCharArray()); + view.showDisplayName(cursor, 0, ContactsContract.Preferences.DISPLAY_ORDER_PRIMARY); + + CharSequence seq = view.getNameTextView().getText(); + assertEquals("John Doe", seq.toString()); + SpannedTestUtils.assertPrefixSpan(seq, 5, 7); + } + + public void testShowDisplayName_WithPrefixReversed() { + Cursor cursor = createCursor("John Doe", "Doe John"); + ContactListItemView view = createView(); + + view.setHighlightedPrefix("DOE".toCharArray()); + view.showDisplayName(cursor, 0, ContactsContract.Preferences.DISPLAY_ORDER_ALTERNATIVE); + + CharSequence seq = view.getNameTextView().getText(); + assertEquals("John Doe", seq.toString()); + SpannedTestUtils.assertPrefixSpan(seq, 5, 7); + } + + public void testSetSnippet_Prefix() { + ContactListItemView view = createView(); + view.setHighlightedPrefix("TEST".toCharArray()); + view.setSnippet("This is a test"); + + CharSequence seq = view.getSnippetView().getText(); + + assertEquals("This is a test", seq.toString()); + SpannedTestUtils.assertPrefixSpan(seq, 10, 13); + } + + /** Creates the view to be tested. */ + private ContactListItemView createView() { + ContactListItemView view = new ContactListItemView(getContext()); + // Set the name view to use a Spannable to represent its content. + view.getNameTextView().setText("", TextView.BufferType.SPANNABLE); + return view; + } + + /** + * Creates a cursor containing a pair of values. + * + * @param name the name to insert in the first column of the cursor + * @param alternateName the alternate name to insert in the second column of the cursor + * @return the newly created cursor + */ + private Cursor createCursor(String name, String alternateName) { + MatrixCursor cursor = new MatrixCursor(new String[]{"Name", "AlternateName"}); + cursor.moveToFirst(); + cursor.addRow(new Object[]{name, alternateName}); + return cursor; + } +} |