diff options
author | Sara Ting <sarating@google.com> | 2012-11-13 13:52:54 -0800 |
---|---|---|
committer | Sara Ting <sarating@google.com> | 2012-11-15 16:13:57 -0800 |
commit | 4686b4065af0137f5d6ff7570d867fed562c8120 (patch) | |
tree | 5ce9e07131065f461ade41fec662394079cea447 | |
parent | ddc1b123074b7243f7d39071c8c39788e44d9079 (diff) | |
download | android_packages_apps_Calendar-4686b4065af0137f5d6ff7570d867fed562c8120.tar.gz android_packages_apps_Calendar-4686b4065af0137f5d6ff7570d867fed562c8120.tar.bz2 android_packages_apps_Calendar-4686b4065af0137f5d6ff7570d867fed562c8120.zip |
Autocomplete location in edit-event with recent locations and contacts' names/addresses.
Bug:6636507
Change-Id: I731edfc453acc8c54b47311bae47cd65a607c763
-rw-r--r-- | res/drawable-hdpi/ic_history_holo_light.png | bin | 0 -> 749 bytes | |||
-rw-r--r-- | res/drawable-mdpi/ic_history_holo_light.png | bin | 0 -> 507 bytes | |||
-rw-r--r-- | res/drawable-xhdpi/ic_history_holo_light.png | bin | 0 -> 916 bytes | |||
-rw-r--r-- | res/layout-sw600dp/edit_event_1.xml | 2 | ||||
-rw-r--r-- | res/layout/edit_event_1.xml | 2 | ||||
-rw-r--r-- | res/layout/location_dropdown_item.xml | 56 | ||||
-rw-r--r-- | src/com/android/calendar/event/EditEventView.java | 38 | ||||
-rw-r--r-- | src/com/android/calendar/event/EventLocationAdapter.java | 471 |
8 files changed, 557 insertions, 12 deletions
diff --git a/res/drawable-hdpi/ic_history_holo_light.png b/res/drawable-hdpi/ic_history_holo_light.png Binary files differnew file mode 100644 index 00000000..d3feeac2 --- /dev/null +++ b/res/drawable-hdpi/ic_history_holo_light.png diff --git a/res/drawable-mdpi/ic_history_holo_light.png b/res/drawable-mdpi/ic_history_holo_light.png Binary files differnew file mode 100644 index 00000000..2b6eec4f --- /dev/null +++ b/res/drawable-mdpi/ic_history_holo_light.png diff --git a/res/drawable-xhdpi/ic_history_holo_light.png b/res/drawable-xhdpi/ic_history_holo_light.png Binary files differnew file mode 100644 index 00000000..83d61fa6 --- /dev/null +++ b/res/drawable-xhdpi/ic_history_holo_light.png diff --git a/res/layout-sw600dp/edit_event_1.xml b/res/layout-sw600dp/edit_event_1.xml index 88ff3b16..f5689f3f 100644 --- a/res/layout-sw600dp/edit_event_1.xml +++ b/res/layout-sw600dp/edit_event_1.xml @@ -88,7 +88,7 @@ <TextView android:text="@string/where_label" style="@style/TextAppearance.EditEvent_Label" /> - <EditText + <AutoCompleteTextView android:id="@+id/location" android:singleLine="false" style="@style/TextAppearance.EditEvent_Value" diff --git a/res/layout/edit_event_1.xml b/res/layout/edit_event_1.xml index 48e6c7f8..be04cff5 100644 --- a/res/layout/edit_event_1.xml +++ b/res/layout/edit_event_1.xml @@ -89,7 +89,7 @@ android:layout_height="wrap_content" android:layout_marginBottom="6dip" android:focusable="true"> - <EditText + <AutoCompleteTextView android:id="@+id/location" android:singleLine="false" android:layout_height="wrap_content" diff --git a/res/layout/location_dropdown_item.xml b/res/layout/location_dropdown_item.xml new file mode 100644 index 00000000..d0fff1bf --- /dev/null +++ b/res/layout/location_dropdown_item.xml @@ -0,0 +1,56 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- 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. +--> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:minHeight="48dip" + android:orientation="horizontal" + android:gravity="center_vertical" + android:background="?android:attr/activatedBackgroundIndicator"> + <ImageView + android:id="@+id/icon" + android:layout_width="48dip" + android:layout_height="48dip" + android:layout_marginLeft="0dip" + android:src="@drawable/ic_contact_picture" + android:cropToPadding="true" + android:scaleType="centerCrop" /> + <LinearLayout + android:layout_width="0dip" + android:layout_height="wrap_content" + android:gravity="center_vertical" + android:orientation="vertical" + android:layout_weight="1"> + <TextView android:id="@+id/location_name" + android:textColor="@drawable/list_item_font_primary" + android:textSize="18sp" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:paddingLeft="8dip" + android:singleLine="true" + android:ellipsize="end" /> + <TextView android:id="@+id/location_address" + android:textColor="@drawable/list_item_font_secondary" + android:textSize="14sp" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:paddingLeft="8dip" + android:singleLine="true" + android:ellipsize="end" + android:layout_marginTop="-4dip" /> + </LinearLayout> +</LinearLayout> diff --git a/src/com/android/calendar/event/EditEventView.java b/src/com/android/calendar/event/EditEventView.java index 3ef527e2..0b25ab9b 100644 --- a/src/com/android/calendar/event/EditEventView.java +++ b/src/com/android/calendar/event/EditEventView.java @@ -140,7 +140,8 @@ public class EditEventView implements View.OnClickListener, DialogInterface.OnCa Spinner mAccessLevelSpinner; RadioGroup mResponseRadioGroup; AutoCompleteTextView mTitleTextView; - TextView mLocationTextView; + AutoCompleteTextView mLocationTextView; + EventLocationAdapter mLocationAdapter; TextView mDescriptionTextView; TextView mWhenView; TextView mTimezoneTextView; @@ -695,14 +696,16 @@ public class EditEventView implements View.OnClickListener, DialogInterface.OnCa Cursor c = uniqueTitlesCursor(tempCursor); // Log the processing duration. - long duration = System.currentTimeMillis() - startTime; - StringBuilder msg = new StringBuilder(); - msg.append("Autocomplete of "); - msg.append(constraint); - msg.append(": title query match took "); - msg.append(duration); - msg.append("ms."); - Log.d(TAG, msg.toString()); + if (Log.isLoggable(TAG, Log.DEBUG)) { + long duration = System.currentTimeMillis() - startTime; + StringBuilder msg = new StringBuilder(); + msg.append("Autocomplete of "); + msg.append(constraint); + msg.append(": title query match took "); + msg.append(duration); + msg.append("ms."); + Log.d(TAG, msg.toString()); + } return c; } finally { tempCursor.close(); @@ -948,7 +951,7 @@ public class EditEventView implements View.OnClickListener, DialogInterface.OnCa // cache all the widgets mCalendarsSpinner = (Spinner) view.findViewById(R.id.calendars_spinner); mTitleTextView = (AutoCompleteTextView) view.findViewById(R.id.title); - mLocationTextView = (TextView) view.findViewById(R.id.location); + mLocationTextView = (AutoCompleteTextView) view.findViewById(R.id.location); mDescriptionTextView = (TextView) view.findViewById(R.id.description); mTimezoneLabel = (TextView) view.findViewById(R.id.timezone_label); mStartDateButton = (Button) view.findViewById(R.id.start_date); @@ -995,6 +998,21 @@ public class EditEventView implements View.OnClickListener, DialogInterface.OnCa }); mLocationTextView.setTag(mLocationTextView.getBackground()); + mLocationAdapter = new EventLocationAdapter(activity); + mLocationTextView.setAdapter(mLocationAdapter); + mLocationTextView.setOnEditorActionListener(new OnEditorActionListener() { + @Override + public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { + if (actionId == EditorInfo.IME_ACTION_DONE) { + // Dismiss the suggestions dropdown. Return false so the other + // side effects still occur (soft keyboard going away, etc.). + mLocationTextView.dismissDropDown(); + } + return false; + } + }); + + mDescriptionTextView.setTag(mDescriptionTextView.getBackground()); mRepeatsSpinner.setTag(mRepeatsSpinner.getBackground()); mAttendeesList.setTag(mAttendeesList.getBackground()); diff --git a/src/com/android/calendar/event/EventLocationAdapter.java b/src/com/android/calendar/event/EventLocationAdapter.java new file mode 100644 index 00000000..f2c9a150 --- /dev/null +++ b/src/com/android/calendar/event/EventLocationAdapter.java @@ -0,0 +1,471 @@ +/* + * 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. + */ + +package com.android.calendar.event; + +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.Context; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.net.Uri; +import android.os.AsyncTask; +import android.provider.CalendarContract.Events; +import android.provider.ContactsContract.CommonDataKinds; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.RawContacts; +import android.text.TextUtils; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.Filter; +import android.widget.Filterable; +import android.widget.ImageView; +import android.widget.TextView; + +import com.android.calendar.R; + +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.TreeSet; +import java.util.concurrent.ExecutionException; + +// TODO: limit length of dropdown to stop at the soft keyboard +// TODO: history icon resize asset + +/** + * An adapter for autocomplete of the location field in edit-event view. + */ +public class EventLocationAdapter extends ArrayAdapter<EventLocationAdapter.Result> + implements Filterable { + private static final String TAG = "EventLocationAdapter"; + + /** + * Internal class for containing info for an item in the auto-complete results. + */ + public static class Result { + private final String mName; + private final String mAddress; + + // The default image resource for the icon. This will be null if there should + // be no icon (if multiple listings for a contact, only the first one should have the + // photo icon). + private final Integer mDefaultIcon; + + // The contact photo to use for the icon. This will override the default icon. + private final Uri mContactPhotoUri; + + public Result(String displayName, String address, Integer defaultIcon, + Uri contactPhotoUri) { + this.mName = displayName; + this.mAddress = address; + this.mDefaultIcon = defaultIcon; + this.mContactPhotoUri = contactPhotoUri; + } + + /** + * This is the autocompleted text. + */ + @Override + public String toString() { + return mAddress; + } + } + private static ArrayList<Result> EMPTY_LIST = new ArrayList<Result>(); + + // Constants for contacts query: + // SELECT ... FROM view_data data WHERE ((data1 LIKE 'input%' OR data1 LIKE '%input%' OR + // display_name LIKE 'input%' OR display_name LIKE '%input%' )) ORDER BY display_name ASC + private static final String[] CONTACTS_PROJECTION = new String[] { + CommonDataKinds.StructuredPostal._ID, + Contacts.DISPLAY_NAME, + CommonDataKinds.StructuredPostal.FORMATTED_ADDRESS, + RawContacts.CONTACT_ID, + Contacts.PHOTO_ID, + }; + private static final int CONTACTS_INDEX_ID = 0; + private static final int CONTACTS_INDEX_DISPLAY_NAME = 1; + private static final int CONTACTS_INDEX_ADDRESS = 2; + private static final int CONTACTS_INDEX_CONTACT_ID = 3; + private static final int CONTACTS_INDEX_PHOTO_ID = 4; + // TODO: Only query visible contacts? + private static final String CONTACTS_WHERE = new StringBuilder() + .append("(") + .append(CommonDataKinds.StructuredPostal.FORMATTED_ADDRESS) + .append(" LIKE ? OR ") + .append(CommonDataKinds.StructuredPostal.FORMATTED_ADDRESS) + .append(" LIKE ? OR ") + .append(Contacts.DISPLAY_NAME) + .append(" LIKE ? OR ") + .append(Contacts.DISPLAY_NAME) + .append(" LIKE ? )") + .toString(); + + // Constants for recent locations query (in Events table): + // SELECT ... FROM view_events WHERE (eventLocation LIKE 'input%') ORDER BY _id DESC + private static final String[] EVENT_PROJECTION = new String[] { + Events._ID, + Events.EVENT_LOCATION, + }; + private static final int EVENT_INDEX_ID = 0; + private static final int EVENT_INDEX_LOCATION = 1; + private static final String LOCATION_WHERE = Events.EVENT_LOCATION + " LIKE ?"; + private static final int MAX_LOCATION_SUGGESTIONS = 4; + + private final ContentResolver mResolver; + private final LayoutInflater mInflater; + private final ArrayList<Result> mResultList = new ArrayList<Result>(); + + // The cache for contacts photos. We don't have to worry about clearing this, as a + // new adapter is created for every edit event. + private final Map<Uri, Bitmap> mPhotoCache = new HashMap<Uri, Bitmap>(); + + /** + * Constructor. + */ + public EventLocationAdapter(Context context) { + super(context, R.layout.location_dropdown_item, EMPTY_LIST); + + mResolver = context.getContentResolver(); + mInflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + } + + @Override + public int getCount() { + return mResultList.size(); + } + + @Override + public Result getItem(int index) { + if (index < mResultList.size()) { + return mResultList.get(index); + } else { + return null; + } + } + + @Override + public View getView(final int position, final View convertView, final ViewGroup parent) { + View view = convertView; + if (view == null) { + view = mInflater.inflate(R.layout.location_dropdown_item, parent, false); + } + final Result result = getItem(position); + if (result == null) { + return view; + } + + // Update the display name in the item in auto-complete list. + TextView nameView = (TextView) view.findViewById(R.id.location_name); + if (nameView != null) { + if (result.mName == null) { + nameView.setVisibility(View.GONE); + } else { + nameView.setVisibility(View.VISIBLE); + nameView.setText(result.mName); + } + } + + // Update the address line. + TextView addressView = (TextView) view.findViewById(R.id.location_address); + if (addressView != null) { + addressView.setText(result.mAddress); + } + + // Update the icon. + final ImageView imageView = (ImageView) view.findViewById(R.id.icon); + if (imageView != null) { + if (result.mDefaultIcon == null) { + imageView.setVisibility(View.INVISIBLE); + } else { + imageView.setVisibility(View.VISIBLE); + imageView.setImageResource(result.mDefaultIcon); + + // Save the URI on the view, so we can check against it later when updating + // the image. Otherwise the async image update with using 'convertView' above + // resulted in the wrong list items being updated. + imageView.setTag(result.mContactPhotoUri); + if (result.mContactPhotoUri != null) { + Bitmap cachedPhoto = mPhotoCache.get(result.mContactPhotoUri); + if (cachedPhoto != null) { + // Use photo in cache. + imageView.setImageBitmap(cachedPhoto); + } else { + // Asynchronously load photo and update. + asyncLoadPhotoAndUpdateView(result.mContactPhotoUri, imageView); + } + } + } + } + return view; + } + + // TODO: Refactor to share code with ContactsAsyncHelper. + private void asyncLoadPhotoAndUpdateView(final Uri contactPhotoUri, + final ImageView imageView) { + AsyncTask<Void, Void, Bitmap> photoUpdaterTask = + new AsyncTask<Void, Void, Bitmap>() { + @Override + protected Bitmap doInBackground(Void... params) { + Bitmap photo = null; + InputStream imageStream = Contacts.openContactPhotoInputStream( + mResolver, contactPhotoUri); + if (imageStream != null) { + photo = BitmapFactory.decodeStream(imageStream); + mPhotoCache.put(contactPhotoUri, photo); + } + return photo; + } + + @Override + public void onPostExecute(Bitmap photo) { + // The View may have already been reused (because using 'convertView' above), so + // we must check the URI is as expected before setting the icon, or we may be + // setting the icon in other items. + if (photo != null && imageView.getTag() == contactPhotoUri) { + imageView.setImageBitmap(photo); + } + } + }.execute(); + } + + /** + * Return filter for matching against contacts info and recent locations. + */ + @Override + public Filter getFilter() { + return new LocationFilter(); + } + + /** + * Filter implementation for matching the input string against contacts info and + * recent locations. + */ + public class LocationFilter extends Filter { + + @Override + protected FilterResults performFiltering(CharSequence constraint) { + long startTime = System.currentTimeMillis(); + final String filter = constraint == null ? "" : constraint.toString(); + if (filter.isEmpty()) { + return null; + } + + // Start the recent locations query (async). + AsyncTask<Void, Void, List<Result>> locationsQueryTask = + new AsyncTask<Void, Void, List<Result>>() { + @Override + protected List<Result> doInBackground(Void... params) { + return queryRecentLocations(mResolver, filter); + } + }.execute(); + + // Perform the contacts query (sync). + HashSet<String> contactsAddresses = new HashSet<String>(); + List<Result> contacts = queryContacts(mResolver, filter, contactsAddresses); + + ArrayList<Result> resultList = new ArrayList<Result>(); + try { + // Wait for the locations query. + List<Result> recentLocations = locationsQueryTask.get(); + + // Add the matched recent locations to returned results. If a match exists in + // both the recent locations query and the contacts addresses, only display it + // as a contacts match. + for (Result recentLocation : recentLocations) { + if (recentLocation.mAddress != null && + !contactsAddresses.contains(recentLocation.mAddress)) { + resultList.add(recentLocation); + } + } + } catch (ExecutionException e) { + Log.e(TAG, "Failed waiting for locations query results.", e); + } catch (InterruptedException e) { + Log.e(TAG, "Failed waiting for locations query results.", e); + } + + // Add all the contacts matches to returned results. + if (contacts != null) { + resultList.addAll(contacts); + } + + // Log the processing duration. + if (Log.isLoggable(TAG, Log.DEBUG)) { + long duration = System.currentTimeMillis() - startTime; + StringBuilder msg = new StringBuilder(); + msg.append("Autocomplete of ").append(constraint); + msg.append(": location query match took ").append(duration).append("ms "); + msg.append("(").append(resultList.size()).append(" results)"); + Log.d(TAG, msg.toString()); + } + + final FilterResults filterResults = new FilterResults(); + filterResults.values = resultList; + filterResults.count = resultList.size(); + return filterResults; + } + + @Override + protected void publishResults(CharSequence constraint, FilterResults results) { + mResultList.clear(); + if (results != null && results.count > 0) { + mResultList.addAll((ArrayList<Result>) results.values); + notifyDataSetChanged(); + } else { + notifyDataSetInvalidated(); + } + } + } + + /** + * Matches the input string against contacts names and addresses. + * + * @param resolver The content resolver. + * @param input The user-typed input string. + * @param addressesRetVal The addresses in the returned result are also returned here + * for faster lookup. Pass in an empty set. + * @return Ordered list of all the matched results. If there are multiple address matches + * for the same contact, they will be listed together in individual items, with only + * the first item containing a name/icon. + */ + private static List<Result> queryContacts(ContentResolver resolver, String input, + HashSet<String> addressesRetVal) { + String where = null; + String[] whereArgs = null; + + // Match any word in contact name or address. + if (!TextUtils.isEmpty(input)) { + where = CONTACTS_WHERE; + String param1 = input + "%"; + String param2 = "% " + input + "%"; + whereArgs = new String[] {param1, param2, param1, param2}; + } + + // Perform the query. + Cursor c = resolver.query(CommonDataKinds.StructuredPostal.CONTENT_URI, + CONTACTS_PROJECTION, where, whereArgs, Contacts.DISPLAY_NAME + " ASC"); + + // Process results. Group together addresses for the same contact. + try { + Map<String, List<Result>> nameToAddresses = new HashMap<String, List<Result>>(); + c.moveToPosition(-1); + while (c.moveToNext()) { + String name = c.getString(CONTACTS_INDEX_DISPLAY_NAME); + String address = c.getString(CONTACTS_INDEX_ADDRESS); + if (name != null) { + + List<Result> addressesForName = nameToAddresses.get(name); + Result result; + if (addressesForName == null) { + // Determine if there is a photo for the icon. + Uri contactPhotoUri = null; + if (c.getLong(CONTACTS_INDEX_PHOTO_ID) > 0) { + contactPhotoUri = ContentUris.withAppendedId(Contacts.CONTENT_URI, + c.getLong(CONTACTS_INDEX_CONTACT_ID)); + } + + // First listing for a distinct contact should have the name/icon. + addressesForName = new ArrayList<Result>(); + nameToAddresses.put(name, addressesForName); + result = new Result(name, address, R.drawable.ic_contact_picture, + contactPhotoUri); + } else { + // Do not include name/icon in subsequent listings for the same contact. + result = new Result(null, address, null, null); + } + + addressesForName.add(result); + addressesRetVal.add(address); + } + } + + // Return the list of results. + List<Result> allResults = new ArrayList<Result>(); + for (List<Result> result : nameToAddresses.values()) { + allResults.addAll(result); + } + return allResults; + + } finally { + if (c != null) { + c.close(); + } + } + } + + /** + * Matches the input string against recent locations. + */ + private static List<Result> queryRecentLocations(ContentResolver resolver, String input) { + // TODO: also match each word in the address? + String filter = input == null ? "" : input + "%"; + if (filter.isEmpty()) { + return null; + } + + // Query all locations prefixed with the constraint. There is no way to insert + // 'DISTINCT' or 'GROUP BY' to get rid of dupes, so use post-processing to + // remove dupes. We will order query results by descending event ID to show + // results that were most recently inputed. + Cursor c = resolver.query(Events.CONTENT_URI, EVENT_PROJECTION, LOCATION_WHERE, + new String[] { filter }, Events._ID + " DESC"); + try { + List<Result> recentLocations = null; + if (c != null) { + // Post process query results. + recentLocations = processLocationsQueryResults(c); + } + return recentLocations; + } finally { + if (c != null) { + c.close(); + } + } + } + + /** + * Post-process the query results to return the first MAX_LOCATION_SUGGESTIONS + * unique locations in alphabetical order. + * + * TODO: Refactor to share code with the recent titles auto-complete. + */ + private static List<Result> processLocationsQueryResults(Cursor cursor) { + TreeSet<String> locations = new TreeSet<String>(String.CASE_INSENSITIVE_ORDER); + int numColumns = cursor.getColumnCount(); + cursor.moveToPosition(-1); + + // Remove dupes. + while ((locations.size() < MAX_LOCATION_SUGGESTIONS) && cursor.moveToNext()) { + String location = cursor.getString(EVENT_INDEX_LOCATION).trim(); + String data[] = new String[numColumns]; + locations.add(location); + } + + // Copy the sorted results. + List<Result> results = new ArrayList<Result>(); + for (String location : locations) { + results.add(new Result(null, location, R.drawable.ic_history_holo_light, null)); + } + return results; + } +} |