1/* 2 * Copyright (C) 2013 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17package com.android.timezonepicker; 18 19import android.content.Context; 20import android.text.TextUtils; 21import android.util.Log; 22import android.view.LayoutInflater; 23import android.view.View; 24import android.view.View.OnClickListener; 25import android.view.ViewGroup; 26import android.widget.BaseAdapter; 27import android.widget.Filter; 28import android.widget.Filterable; 29import android.widget.TextView; 30 31import java.util.ArrayList; 32import java.util.Collections; 33 34public class TimeZoneFilterTypeAdapter extends BaseAdapter implements Filterable, OnClickListener { 35 public static final String TAG = "TimeZoneFilterTypeAdapter"; 36 37 private static final boolean DEBUG = false; 38 39 public static final int FILTER_TYPE_EMPTY = -1; 40 public static final int FILTER_TYPE_NONE = 0; 41 public static final int FILTER_TYPE_COUNTRY = 1; 42 public static final int FILTER_TYPE_STATE = 2; 43 public static final int FILTER_TYPE_GMT = 3; 44 45 public interface OnSetFilterListener { 46 void onSetFilter(int filterType, String str, int time); 47 } 48 49 static class ViewHolder { 50 int filterType; 51 String str; 52 int time; 53 TextView strTextView; 54 55 static void setupViewHolder(View v) { 56 ViewHolder vh = new ViewHolder(); 57 vh.strTextView = (TextView) v.findViewById(R.id.value); 58 v.setTag(vh); 59 } 60 } 61 62 class FilterTypeResult { 63 int type; 64 String constraint; 65 public int time; 66 67 public FilterTypeResult(int type, String constraint, int time) { 68 this.type = type; 69 this.constraint = constraint; 70 this.time = time; 71 } 72 73 @Override 74 public String toString() { 75 return constraint; 76 } 77 } 78 79 private ArrayList<FilterTypeResult> mLiveResults = new ArrayList<FilterTypeResult>(); 80 private int mLiveResultsCount = 0; 81 82 private ArrayFilter mFilter; 83 84 private LayoutInflater mInflater; 85 86 private TimeZoneData mTimeZoneData; 87 private OnSetFilterListener mListener; 88 89 public TimeZoneFilterTypeAdapter(Context context, TimeZoneData tzd, OnSetFilterListener l) { 90 mTimeZoneData = tzd; 91 mListener = l; 92 mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); 93 } 94 95 @Override 96 public int getCount() { 97 return mLiveResultsCount; 98 } 99 100 @Override 101 public FilterTypeResult getItem(int position) { 102 return mLiveResults.get(position); 103 } 104 105 @Override 106 public long getItemId(int position) { 107 return position; 108 } 109 110 @Override 111 public View getView(int position, View convertView, ViewGroup parent) { 112 View v; 113 114 if (convertView != null) { 115 v = convertView; 116 } else { 117 v = mInflater.inflate(R.layout.time_zone_filter_item, null); 118 ViewHolder.setupViewHolder(v); 119 } 120 121 ViewHolder vh = (ViewHolder) v.getTag(); 122 123 if (position >= mLiveResults.size()) { 124 Log.e(TAG, "getView: " + position + " of " + mLiveResults.size()); 125 } 126 127 FilterTypeResult filter = mLiveResults.get(position); 128 129 vh.filterType = filter.type; 130 vh.str = filter.constraint; 131 vh.time = filter.time; 132 vh.strTextView.setText(filter.constraint); 133 return v; 134 } 135 136 OnClickListener mDummyListener = new OnClickListener() { 137 138 @Override 139 public void onClick(View v) { 140 } 141 }; 142 143 // Implements OnClickListener 144 145 // This onClickListener is actually called from the AutoCompleteTextView's 146 // onItemClickListener. Trying to update the text in AutoCompleteTextView 147 // is causing an infinite loop. 148 @Override 149 public void onClick(View v) { 150 if (mListener != null && v != null) { 151 ViewHolder vh = (ViewHolder) v.getTag(); 152 mListener.onSetFilter(vh.filterType, vh.str, vh.time); 153 } 154 notifyDataSetInvalidated(); 155 } 156 157 // Implements Filterable 158 @Override 159 public Filter getFilter() { 160 if (mFilter == null) { 161 mFilter = new ArrayFilter(); 162 } 163 return mFilter; 164 } 165 166 private class ArrayFilter extends Filter { 167 @Override 168 protected FilterResults performFiltering(CharSequence prefix) { 169 if (DEBUG) { 170 Log.d(TAG, "performFiltering >>>> [" + prefix + "]"); 171 } 172 173 FilterResults results = new FilterResults(); 174 String prefixString = null; 175 if (prefix != null) { 176 prefixString = prefix.toString().trim().toLowerCase(); 177 } 178 179 if (TextUtils.isEmpty(prefixString)) { 180 results.values = null; 181 results.count = 0; 182 return results; 183 } 184 185 // TODO Perf - we can loop through the filtered list if the new 186 // search string starts with the old search string 187 ArrayList<FilterTypeResult> filtered = new ArrayList<FilterTypeResult>(); 188 189 // //////////////////////////////////////// 190 // Search by local time and GMT offset 191 // //////////////////////////////////////// 192 boolean gmtOnly = false; 193 int startParsePosition = 0; 194 if (prefixString.charAt(0) == '+' || prefixString.charAt(0) == '-') { 195 gmtOnly = true; 196 } 197 198 if (prefixString.startsWith("gmt")) { 199 startParsePosition = 3; 200 gmtOnly = true; 201 } 202 203 int num = parseNum(prefixString, startParsePosition); 204 if (num != Integer.MIN_VALUE) { 205 boolean positiveOnly = prefixString.length() > startParsePosition 206 && prefixString.charAt(startParsePosition) == '+'; 207 handleSearchByGmt(filtered, num, positiveOnly); 208 } 209 210 // //////////////////////////////////////// 211 // Search by country 212 // //////////////////////////////////////// 213 ArrayList<String> countries = new ArrayList<String>(); 214 for (String country : mTimeZoneData.mTimeZonesByCountry.keySet()) { 215 // TODO Perf - cache toLowerCase()? 216 if (!TextUtils.isEmpty(country)) { 217 final String lowerCaseCountry = country.toLowerCase(); 218 boolean isMatch = false; 219 if (lowerCaseCountry.startsWith(prefixString) 220 || (lowerCaseCountry.charAt(0) == prefixString.charAt(0) && 221 isStartingInitialsFor(prefixString, lowerCaseCountry))) { 222 isMatch = true; 223 } else if (lowerCaseCountry.contains(" ")){ 224 // We should also search other words in the country name, so that 225 // searches like "Korea" yield "South Korea". 226 for (String word : lowerCaseCountry.split(" ")) { 227 if (word.startsWith(prefixString)) { 228 isMatch = true; 229 break; 230 } 231 } 232 } 233 if (isMatch) { 234 countries.add(country); 235 } 236 } 237 } 238 if (countries.size() > 0) { 239 // Sort countries alphabetically. 240 Collections.sort(countries); 241 for (String country : countries) { 242 filtered.add(new FilterTypeResult(FILTER_TYPE_COUNTRY, country, 0)); 243 } 244 } 245 246 // //////////////////////////////////////// 247 // TODO Search by state 248 // //////////////////////////////////////// 249 if (DEBUG) { 250 Log.d(TAG, "performFiltering <<<< " + filtered.size() + "[" + prefix + "]"); 251 } 252 253 results.values = filtered; 254 results.count = filtered.size(); 255 return results; 256 } 257 258 /** 259 * Returns true if the prefixString is an initial for string. Note that 260 * this method will return true even if prefixString does not cover all 261 * the words. Words are separated by non-letters which includes spaces 262 * and symbols). 263 * 264 * For example: 265 * isStartingInitialsFor("UA", "United Arab Emirates") would return true 266 * isStartingInitialsFor("US", "U.S. Virgin Island") would return true 267 * 268 * @param prefixString 269 * @param string 270 * @return 271 */ 272 private boolean isStartingInitialsFor(String prefixString, String string) { 273 final int initialLen = prefixString.length(); 274 final int strLen = string.length(); 275 276 int initialIdx = 0; 277 boolean wasWordBreak = true; 278 for (int i = 0; i < strLen; i++) { 279 if (!Character.isLetter(string.charAt(i))) { 280 wasWordBreak = true; 281 continue; 282 } 283 284 if (wasWordBreak) { 285 if (prefixString.charAt(initialIdx++) != string.charAt(i)) { 286 return false; 287 } 288 if (initialIdx == initialLen) { 289 return true; 290 } 291 wasWordBreak = false; 292 } 293 } 294 295 // Special case for "USA". Note that both strings have been turned to lowercase already. 296 if (prefixString.equals("usa") && string.equals("united states")) { 297 return true; 298 } 299 return false; 300 } 301 302 private void handleSearchByGmt(ArrayList<FilterTypeResult> filtered, int num, 303 boolean positiveOnly) { 304 305 FilterTypeResult r; 306 if (num >= 0) { 307 if (num == 1) { 308 for (int i = 19; i >= 10; i--) { 309 if (mTimeZoneData.hasTimeZonesInHrOffset(i)) { 310 r = new FilterTypeResult(FILTER_TYPE_GMT, "GMT+" + i, i); 311 filtered.add(r); 312 } 313 } 314 } 315 316 if (mTimeZoneData.hasTimeZonesInHrOffset(num)) { 317 r = new FilterTypeResult(FILTER_TYPE_GMT, "GMT+" + num, num); 318 filtered.add(r); 319 } 320 num *= -1; 321 } 322 323 if (!positiveOnly && num != 0) { 324 if (mTimeZoneData.hasTimeZonesInHrOffset(num)) { 325 r = new FilterTypeResult(FILTER_TYPE_GMT, "GMT" + num, num); 326 filtered.add(r); 327 } 328 329 if (num == -1) { 330 for (int i = -10; i >= -19; i--) { 331 if (mTimeZoneData.hasTimeZonesInHrOffset(i)) { 332 r = new FilterTypeResult(FILTER_TYPE_GMT, "GMT" + i, i); 333 filtered.add(r); 334 } 335 } 336 } 337 } 338 } 339 340 /** 341 * Acceptable strings are in the following format: [+-]?[0-9]?[0-9] 342 * 343 * @param str 344 * @param startIndex 345 * @return Integer.MIN_VALUE as invalid 346 */ 347 public int parseNum(String str, int startIndex) { 348 int idx = startIndex; 349 int num = Integer.MIN_VALUE; 350 int negativeMultiplier = 1; 351 352 // First char - check for + and - 353 char ch = str.charAt(idx++); 354 switch (ch) { 355 case '-': 356 negativeMultiplier = -1; 357 // fall through 358 case '+': 359 if (idx >= str.length()) { 360 // No more digits 361 return Integer.MIN_VALUE; 362 } 363 364 ch = str.charAt(idx++); 365 break; 366 } 367 368 if (!Character.isDigit(ch)) { 369 // No digit 370 return Integer.MIN_VALUE; 371 } 372 373 // Got first digit 374 num = Character.digit(ch, 10); 375 376 // Check next char 377 if (idx < str.length()) { 378 ch = str.charAt(idx++); 379 if (Character.isDigit(ch)) { 380 // Got second digit 381 num = 10 * num + Character.digit(ch, 10); 382 } else { 383 return Integer.MIN_VALUE; 384 } 385 } 386 387 if (idx != str.length()) { 388 // Invalid 389 return Integer.MIN_VALUE; 390 } 391 392 if (DEBUG) { 393 Log.d(TAG, "Parsing " + str + " -> " + negativeMultiplier * num); 394 } 395 return negativeMultiplier * num; 396 } 397 398 @SuppressWarnings("unchecked") 399 @Override 400 protected void publishResults(CharSequence constraint, FilterResults 401 results) { 402 if (results.values == null || results.count == 0) { 403 if (mListener != null) { 404 int filterType; 405 if (TextUtils.isEmpty(constraint)) { 406 filterType = FILTER_TYPE_NONE; 407 } else { 408 filterType = FILTER_TYPE_EMPTY; 409 } 410 mListener.onSetFilter(filterType, null, 0); 411 } 412 if (DEBUG) { 413 Log.d(TAG, "publishResults: " + results.count + " of null [" + constraint); 414 } 415 } else { 416 mLiveResults = (ArrayList<FilterTypeResult>) results.values; 417 if (DEBUG) { 418 Log.d(TAG, "publishResults: " + results.count + " of " + mLiveResults.size() 419 + " [" + constraint); 420 } 421 } 422 mLiveResultsCount = results.count; 423 424 if (results.count > 0) { 425 notifyDataSetChanged(); 426 } else { 427 notifyDataSetInvalidated(); 428 } 429 } 430 } 431} 432