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