1/* 2 * Copyright (C) 2012 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.deskclock.worldclock; 18 19import android.app.ActionBar; 20import android.app.Activity; 21import android.content.ActivityNotFoundException; 22import android.content.Context; 23import android.content.Intent; 24import android.content.SharedPreferences; 25import android.os.Bundle; 26import android.preference.PreferenceManager; 27import android.text.TextUtils; 28import android.text.format.DateFormat; 29import android.util.TypedValue; 30import android.view.LayoutInflater; 31import android.view.Menu; 32import android.view.MenuItem; 33import android.view.View; 34import android.view.View.OnClickListener; 35import android.view.ViewGroup; 36import android.view.inputmethod.EditorInfo; 37import android.widget.BaseAdapter; 38import android.widget.CheckBox; 39import android.widget.CompoundButton; 40import android.widget.CompoundButton.OnCheckedChangeListener; 41import android.widget.Filter; 42import android.widget.Filterable; 43import android.widget.ListView; 44import android.widget.SearchView; 45import android.widget.SearchView.OnQueryTextListener; 46import android.widget.SectionIndexer; 47import android.widget.TextView; 48 49import com.android.deskclock.R; 50import com.android.deskclock.SettingsActivity; 51import com.android.deskclock.Utils; 52 53import java.util.ArrayList; 54import java.util.Arrays; 55import java.util.Calendar; 56import java.util.Collection; 57import java.util.HashMap; 58import java.util.HashSet; 59import java.util.List; 60import java.util.Locale; 61import java.util.TimeZone; 62 63/** 64 * Cities chooser for the world clock 65 */ 66public class CitiesActivity extends Activity implements OnCheckedChangeListener, 67 View.OnClickListener, OnQueryTextListener { 68 69 private static final String KEY_SEARCH_QUERY = "search_query"; 70 private static final String KEY_SEARCH_MODE = "search_mode"; 71 private static final String KEY_LIST_POSITION = "list_position"; 72 73 private static final String PREF_SORT = "sort_preference"; 74 75 private static final int SORT_BY_NAME = 0; 76 private static final int SORT_BY_GMT_OFFSET = 1; 77 78 /** 79 * This must be false for production. If true, turns on logging, test code, 80 * etc. 81 */ 82 static final boolean DEBUG = false; 83 static final String TAG = "CitiesActivity"; 84 85 private LayoutInflater mFactory; 86 private ListView mCitiesList; 87 private CityAdapter mAdapter; 88 private HashMap<String, CityObj> mUserSelectedCities; 89 private Calendar mCalendar; 90 91 private SearchView mSearchView; 92 private StringBuffer mQueryTextBuffer = new StringBuffer(); 93 private boolean mSearchMode; 94 private int mPosition = -1; 95 96 private SharedPreferences mPrefs; 97 private int mSortType; 98 99 private String mSelectedCitiesHeaderString; 100 101 /*** 102 * Adapter for a list of cities with the respected time zone. The Adapter 103 * sorts the list alphabetically and create an indexer. 104 ***/ 105 private class CityAdapter extends BaseAdapter implements Filterable, SectionIndexer { 106 private static final int VIEW_TYPE_CITY = 0; 107 private static final int VIEW_TYPE_HEADER = 1; 108 109 private static final String DELETED_ENTRY = "C0"; 110 111 private List<CityObj> mDisplayedCitiesList; 112 113 private CityObj[] mCities; 114 private CityObj[] mSelectedCities; 115 116 private final int mLayoutDirection; 117 118 // A map that caches names of cities in local memory. The names in this map are 119 // preferred over the names of the selected cities stored in SharedPreferences, which could 120 // be in a different language. This map gets reloaded on a locale change, when the new 121 // language's city strings are read from the xml file. 122 private HashMap<String, String> mCityNameMap = new HashMap<String, String>(); 123 124 private String[] mSectionHeaders; 125 private Integer[] mSectionPositions; 126 127 private CityNameComparator mSortByNameComparator = new CityNameComparator(); 128 private CityGmtOffsetComparator mSortByTimeComparator = new CityGmtOffsetComparator(); 129 130 private final LayoutInflater mInflater; 131 private boolean mIs24HoursMode; // AM/PM or 24 hours mode 132 133 private final String mPattern12; 134 private final String mPattern24; 135 136 private int mSelectedEndPosition = 0; 137 138 private Filter mFilter = new Filter() { 139 140 @Override 141 protected synchronized FilterResults performFiltering(CharSequence constraint) { 142 FilterResults results = new FilterResults(); 143 String modifiedQuery = constraint.toString().trim().toUpperCase(); 144 145 ArrayList<CityObj> filteredList = new ArrayList<CityObj>(); 146 ArrayList<String> sectionHeaders = new ArrayList<String>(); 147 ArrayList<Integer> sectionPositions = new ArrayList<Integer>(); 148 149 // Update the list first when user using search filter 150 final Collection<CityObj> selectedCities = mUserSelectedCities.values(); 151 mSelectedCities = selectedCities.toArray(new CityObj[selectedCities.size()]); 152 // If the search query is empty, add in the selected cities 153 if (TextUtils.isEmpty(modifiedQuery) && mSelectedCities != null) { 154 if (mSelectedCities.length > 0) { 155 sectionHeaders.add("+"); 156 sectionPositions.add(0); 157 filteredList.add(new CityObj(mSelectedCitiesHeaderString, 158 mSelectedCitiesHeaderString, 159 null)); 160 } 161 for (CityObj city : mSelectedCities) { 162 city.isHeader = false; 163 filteredList.add(city); 164 } 165 } 166 167 final HashSet<String> selectedCityIds = new HashSet<>(); 168 for (CityObj c : mSelectedCities) { 169 selectedCityIds.add(c.mCityId); 170 } 171 mSelectedEndPosition = filteredList.size(); 172 173 long currentTime = System.currentTimeMillis(); 174 String val = null; 175 int offset = -100000; //some value that cannot be a real offset 176 for (CityObj city : mCities) { 177 178 // If the city is a deleted entry, ignore it. 179 if (city.mCityId.equals(DELETED_ENTRY)) { 180 continue; 181 } 182 183 // If the search query is empty, add section headers. 184 if (TextUtils.isEmpty(modifiedQuery)) { 185 if (!selectedCityIds.contains(city.mCityId)) { 186 // If the list is sorted by name, and the city begins with a letter 187 // different than the previous city's letter, insert a section header. 188 if (mSortType == SORT_BY_NAME 189 && !city.mCityName.substring(0, 1).equals(val)) { 190 val = city.mCityName.substring(0, 1).toUpperCase(); 191 sectionHeaders.add(val); 192 sectionPositions.add(filteredList.size()); 193 city.isHeader = true; 194 } else { 195 city.isHeader = false; 196 } 197 198 // If the list is sorted by time, and the gmt offset is different than 199 // the previous city's gmt offset, insert a section header. 200 if (mSortType == SORT_BY_GMT_OFFSET) { 201 TimeZone timezone = TimeZone.getTimeZone(city.mTimeZone); 202 int newOffset = timezone.getOffset(currentTime); 203 if (offset != newOffset) { 204 offset = newOffset; 205 String offsetString = Utils.getGMTHourOffset(timezone, true); 206 sectionHeaders.add(offsetString); 207 sectionPositions.add(filteredList.size()); 208 city.isHeader = true; 209 } else { 210 city.isHeader = false; 211 } 212 } 213 214 filteredList.add(city); 215 } 216 } else { 217 // If the city name begins with the non-empty query, add it into the list. 218 String cityName = city.mCityName.trim().toUpperCase(); 219 if (city.mCityId != null && cityName.startsWith(modifiedQuery)) { 220 city.isHeader = false; 221 filteredList.add(city); 222 } 223 } 224 } 225 226 mSectionHeaders = sectionHeaders.toArray(new String[sectionHeaders.size()]); 227 mSectionPositions = sectionPositions.toArray(new Integer[sectionPositions.size()]); 228 229 results.values = filteredList; 230 results.count = filteredList.size(); 231 return results; 232 } 233 234 @Override 235 protected void publishResults(CharSequence constraint, FilterResults results) { 236 mDisplayedCitiesList = (ArrayList<CityObj>) results.values; 237 if (mPosition >= 0) { 238 mCitiesList.setSelectionFromTop(mPosition, 0); 239 mPosition = -1; 240 } 241 notifyDataSetChanged(); 242 } 243 }; 244 245 public CityAdapter( 246 Context context, LayoutInflater factory) { 247 super(); 248 mCalendar = Calendar.getInstance(); 249 mCalendar.setTimeInMillis(System.currentTimeMillis()); 250 mLayoutDirection = TextUtils.getLayoutDirectionFromLocale(Locale.getDefault()); 251 mInflater = factory; 252 253 // Load the cities from xml. 254 mCities = Utils.loadCitiesFromXml(context); 255 256 // Reload the city name map with the recently parsed city names of the currently 257 // selected language for use with selected cities. 258 mCityNameMap.clear(); 259 for (CityObj city : mCities) { 260 mCityNameMap.put(city.mCityId, city.mCityName); 261 } 262 263 // Re-organize the selected cities into an array. 264 Collection<CityObj> selectedCities = mUserSelectedCities.values(); 265 mSelectedCities = selectedCities.toArray(new CityObj[selectedCities.size()]); 266 267 // Override the selected city names in the shared preferences with the 268 // city names in the updated city name map, which will always reflect the 269 // current language. 270 for (CityObj city : mSelectedCities) { 271 String newCityName = mCityNameMap.get(city.mCityId); 272 if (newCityName != null) { 273 city.mCityName = newCityName; 274 } 275 } 276 277 mPattern24 = DateFormat.getBestDateTimePattern(Locale.getDefault(), "Hm"); 278 279 // There's an RTL layout bug that causes jank when fast-scrolling through 280 // the list in 12-hour mode in an RTL locale. We can work around this by 281 // ensuring the strings are the same length by using "hh" instead of "h". 282 String pattern12 = DateFormat.getBestDateTimePattern(Locale.getDefault(), "hma"); 283 if (mLayoutDirection == View.LAYOUT_DIRECTION_RTL) { 284 pattern12 = pattern12.replaceAll("h", "hh"); 285 } 286 mPattern12 = pattern12; 287 288 sortCities(mSortType); 289 set24HoursMode(context); 290 } 291 292 public void toggleSort() { 293 if (mSortType == SORT_BY_NAME) { 294 sortCities(SORT_BY_GMT_OFFSET); 295 } else { 296 sortCities(SORT_BY_NAME); 297 } 298 } 299 300 private void sortCities(final int sortType) { 301 mSortType = sortType; 302 Arrays.sort(mCities, sortType == SORT_BY_NAME ? mSortByNameComparator 303 : mSortByTimeComparator); 304 if (mSelectedCities != null) { 305 Arrays.sort(mSelectedCities, sortType == SORT_BY_NAME ? mSortByNameComparator 306 : mSortByTimeComparator); 307 } 308 mPrefs.edit().putInt(PREF_SORT, sortType).commit(); 309 mFilter.filter(mQueryTextBuffer.toString()); 310 } 311 312 @Override 313 public int getCount() { 314 return mDisplayedCitiesList != null ? mDisplayedCitiesList.size() : 0; 315 } 316 317 @Override 318 public Object getItem(int p) { 319 if (mDisplayedCitiesList != null && p >= 0 && p < mDisplayedCitiesList.size()) { 320 return mDisplayedCitiesList.get(p); 321 } 322 return null; 323 } 324 325 @Override 326 public long getItemId(int p) { 327 return p; 328 } 329 330 @Override 331 public boolean isEnabled(int p) { 332 return mDisplayedCitiesList != null && mDisplayedCitiesList.get(p).mCityId != null; 333 } 334 335 @Override 336 public synchronized View getView(int position, View view, ViewGroup parent) { 337 if (mDisplayedCitiesList == null || position < 0 338 || position >= mDisplayedCitiesList.size()) { 339 return null; 340 } 341 CityObj c = mDisplayedCitiesList.get(position); 342 // Header view: A CityObj with nothing but the "selected cities" label 343 if (c.mCityId == null) { 344 if (view == null) { 345 view = mInflater.inflate(R.layout.city_list_header, parent, false); 346 } 347 } else { // City view 348 // Make sure to recycle a City view only 349 if (view == null) { 350 view = mInflater.inflate(R.layout.city_list_item, parent, false); 351 final CityViewHolder holder = new CityViewHolder(); 352 holder.index = (TextView) view.findViewById(R.id.index); 353 holder.name = (TextView) view.findViewById(R.id.city_name); 354 holder.time = (TextView) view.findViewById(R.id.city_time); 355 holder.selected = (CheckBox) view.findViewById(R.id.city_onoff); 356 view.setTag(holder); 357 } 358 view.setOnClickListener(CitiesActivity.this); 359 CityViewHolder holder = (CityViewHolder) view.getTag(); 360 361 holder.selected.setTag(c); 362 holder.selected.setChecked(mUserSelectedCities.containsKey(c.mCityId)); 363 holder.selected.setOnCheckedChangeListener(CitiesActivity.this); 364 holder.name.setText(c.mCityName, TextView.BufferType.SPANNABLE); 365 holder.time.setText(getTimeCharSequence(c.mTimeZone)); 366 if (c.isHeader) { 367 holder.index.setVisibility(View.VISIBLE); 368 if (mSortType == SORT_BY_NAME) { 369 holder.index.setText(c.mCityName.substring(0, 1)); 370 holder.index.setTextSize(TypedValue.COMPLEX_UNIT_SP, 24); 371 } else { // SORT_BY_GMT_OFFSET 372 holder.index.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14); 373 holder.index.setText(Utils.getGMTHourOffset( 374 TimeZone.getTimeZone(c.mTimeZone), true)); 375 } 376 } else { 377 // If not a header, use the invisible index for left padding 378 holder.index.setVisibility(View.INVISIBLE); 379 } 380 // skip checkbox and other animations 381 view.jumpDrawablesToCurrentState(); 382 } 383 return view; 384 } 385 386 private CharSequence getTimeCharSequence(String timeZone) { 387 mCalendar.setTimeZone(TimeZone.getTimeZone(timeZone)); 388 return DateFormat.format(mIs24HoursMode ? mPattern24 : mPattern12, mCalendar); 389 } 390 391 @Override 392 public int getViewTypeCount() { 393 return 2; 394 } 395 396 @Override 397 public int getItemViewType(int position) { 398 return (mDisplayedCitiesList.get(position).mCityId != null) 399 ? VIEW_TYPE_CITY : VIEW_TYPE_HEADER; 400 } 401 402 private class CityViewHolder { 403 TextView index; 404 TextView name; 405 TextView time; 406 CheckBox selected; 407 } 408 409 public void set24HoursMode(Context c) { 410 mIs24HoursMode = DateFormat.is24HourFormat(c); 411 notifyDataSetChanged(); 412 } 413 414 @Override 415 public int getPositionForSection(int section) { 416 return !isEmpty(mSectionPositions) ? mSectionPositions[section] : 0; 417 } 418 419 420 @Override 421 public int getSectionForPosition(int p) { 422 final Integer[] positions = mSectionPositions; 423 if (!isEmpty(positions)) { 424 for (int i = 0; i < positions.length - 1; i++) { 425 if (p >= positions[i] 426 && p < positions[i + 1]) { 427 return i; 428 } 429 } 430 if (p >= positions[positions.length - 1]) { 431 return positions.length - 1; 432 } 433 } 434 return 0; 435 } 436 437 @Override 438 public Object[] getSections() { 439 return mSectionHeaders; 440 } 441 442 @Override 443 public Filter getFilter() { 444 return mFilter; 445 } 446 447 private boolean isEmpty(Object[] array) { 448 return array == null || array.length == 0; 449 } 450 } 451 452 @Override 453 protected void onCreate(Bundle savedInstanceState) { 454 super.onCreate(savedInstanceState); 455 mFactory = LayoutInflater.from(this); 456 mPrefs = PreferenceManager.getDefaultSharedPreferences(this); 457 mSortType = mPrefs.getInt(PREF_SORT, SORT_BY_NAME); 458 mSelectedCitiesHeaderString = getString(R.string.selected_cities_label); 459 if (savedInstanceState != null) { 460 mQueryTextBuffer.append(savedInstanceState.getString(KEY_SEARCH_QUERY)); 461 mSearchMode = savedInstanceState.getBoolean(KEY_SEARCH_MODE); 462 mPosition = savedInstanceState.getInt(KEY_LIST_POSITION); 463 } 464 updateLayout(); 465 } 466 467 @Override 468 public void onSaveInstanceState(Bundle bundle) { 469 super.onSaveInstanceState(bundle); 470 bundle.putString(KEY_SEARCH_QUERY, mQueryTextBuffer.toString()); 471 bundle.putBoolean(KEY_SEARCH_MODE, mSearchMode); 472 bundle.putInt(KEY_LIST_POSITION, mCitiesList.getFirstVisiblePosition()); 473 } 474 475 private void updateLayout() { 476 setContentView(R.layout.cities_activity); 477 mCitiesList = (ListView) findViewById(R.id.cities_list); 478 setFastScroll(TextUtils.isEmpty(mQueryTextBuffer.toString().trim())); 479 mCitiesList.setScrollBarStyle(View.SCROLLBARS_INSIDE_INSET); 480 mUserSelectedCities = Cities.readCitiesFromSharedPrefs( 481 PreferenceManager.getDefaultSharedPreferences(this)); 482 mAdapter = new CityAdapter(this, mFactory); 483 mCitiesList.setAdapter(mAdapter); 484 ActionBar actionBar = getActionBar(); 485 if (actionBar != null) { 486 actionBar.setDisplayOptions(ActionBar.DISPLAY_HOME_AS_UP, ActionBar.DISPLAY_HOME_AS_UP); 487 } 488 } 489 490 private void setFastScroll(boolean enabled) { 491 if (mCitiesList != null) { 492 mCitiesList.setFastScrollAlwaysVisible(enabled); 493 mCitiesList.setFastScrollEnabled(enabled); 494 } 495 } 496 497 @Override 498 public void onResume() { 499 super.onResume(); 500 if (mAdapter != null) { 501 mAdapter.set24HoursMode(this); 502 } 503 504 getWindow().getDecorView().setBackgroundColor(Utils.getCurrentHourColor()); 505 } 506 507 @Override 508 public void onPause() { 509 super.onPause(); 510 Cities.saveCitiesToSharedPrefs(PreferenceManager.getDefaultSharedPreferences(this), 511 mUserSelectedCities); 512 Intent i = new Intent(Cities.WORLDCLOCK_UPDATE_INTENT); 513 sendBroadcast(i); 514 } 515 516 @Override 517 public boolean onOptionsItemSelected(MenuItem item) { 518 switch (item.getItemId()) { 519 case android.R.id.home: 520 finish(); 521 return true; 522 case R.id.menu_item_settings: 523 startActivity(new Intent(this, SettingsActivity.class)); 524 return true; 525 case R.id.menu_item_help: 526 Intent i = item.getIntent(); 527 if (i != null) { 528 try { 529 startActivity(i); 530 } catch (ActivityNotFoundException e) { 531 // No activity found to match the intent - ignore 532 } 533 } 534 return true; 535 case R.id.menu_item_sort: 536 if (mAdapter != null) { 537 mAdapter.toggleSort(); 538 setFastScroll(TextUtils.isEmpty(mQueryTextBuffer.toString().trim())); 539 } 540 return true; 541 default: 542 break; 543 } 544 return super.onOptionsItemSelected(item); 545 } 546 547 @Override 548 public boolean onCreateOptionsMenu(Menu menu) { 549 getMenuInflater().inflate(R.menu.cities_menu, menu); 550 MenuItem help = menu.findItem(R.id.menu_item_help); 551 if (help != null) { 552 Utils.prepareHelpMenuItem(this, help); 553 } 554 555 MenuItem searchMenu = menu.findItem(R.id.menu_item_search); 556 mSearchView = (SearchView) searchMenu.getActionView(); 557 mSearchView.setImeOptions(EditorInfo.IME_FLAG_NO_EXTRACT_UI); 558 mSearchView.setOnSearchClickListener(new OnClickListener() { 559 560 @Override 561 public void onClick(View arg0) { 562 mSearchMode = true; 563 } 564 }); 565 mSearchView.setOnCloseListener(new SearchView.OnCloseListener() { 566 567 @Override 568 public boolean onClose() { 569 mSearchMode = false; 570 return false; 571 } 572 }); 573 if (mSearchView != null) { 574 mSearchView.setOnQueryTextListener(this); 575 mSearchView.setQuery(mQueryTextBuffer.toString(), false); 576 if (mSearchMode) { 577 mSearchView.requestFocus(); 578 mSearchView.setIconified(false); 579 } 580 } 581 return super.onCreateOptionsMenu(menu); 582 } 583 584 @Override 585 public boolean onPrepareOptionsMenu(Menu menu) { 586 MenuItem sortMenuItem = menu.findItem(R.id.menu_item_sort); 587 if (mSortType == SORT_BY_NAME) { 588 sortMenuItem.setTitle(getString(R.string.menu_item_sort_by_gmt_offset)); 589 } else { 590 sortMenuItem.setTitle(getString(R.string.menu_item_sort_by_name)); 591 } 592 return super.onPrepareOptionsMenu(menu); 593 } 594 595 @Override 596 public void onCheckedChanged(CompoundButton b, boolean checked) { 597 CityObj c = (CityObj) b.getTag(); 598 if (checked) { 599 mUserSelectedCities.put(c.mCityId, c); 600 } else { 601 mUserSelectedCities.remove(c.mCityId); 602 } 603 } 604 605 @Override 606 public void onClick(View v) { 607 CompoundButton b = (CompoundButton) v.findViewById(R.id.city_onoff); 608 boolean checked = b.isChecked(); 609 onCheckedChanged(b, checked); 610 b.setChecked(!checked); 611 } 612 613 @Override 614 public boolean onQueryTextChange(String queryText) { 615 mQueryTextBuffer.setLength(0); 616 mQueryTextBuffer.append(queryText); 617 mCitiesList.setFastScrollEnabled(TextUtils.isEmpty(mQueryTextBuffer.toString().trim())); 618 mAdapter.getFilter().filter(queryText); 619 return true; 620 } 621 622 @Override 623 public boolean onQueryTextSubmit(String arg0) { 624 return false; 625 } 626} 627