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.os.Bundle; 26 import android.preference.PreferenceManager; 27 import android.text.TextUtils; 28 import android.text.format.DateFormat; 29 import android.view.LayoutInflater; 30 import android.view.Menu; 31 import android.view.MenuItem; 32 import android.view.View; 33 import android.view.View.OnClickListener; 34 import android.view.ViewGroup; 35 import android.view.inputmethod.EditorInfo; 36 import android.widget.BaseAdapter; 37 import android.widget.CheckBox; 38 import android.widget.CompoundButton; 39 import android.widget.CompoundButton.OnCheckedChangeListener; 40 import android.widget.Filter; 41 import android.widget.Filterable; 42 import android.widget.ImageView; 43 import android.widget.ListView; 44 import android.widget.SearchView; 45 import android.widget.SearchView.OnQueryTextListener; 46 import android.widget.SectionIndexer; 47 import android.widget.TextView; 48 49 import com.android.deskclock.DeskClock; 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.List; 60 import java.util.Locale; 61 import java.util.TimeZone; 62 63 /** 64 * Cities chooser for the world clock 65 */ 66 public 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 // If the search query is empty, add in the selected cities 150 if (TextUtils.isEmpty(modifiedQuery) && mSelectedCities != null) { 151 if (mSelectedCities.length > 0) { 152 sectionHeaders.add("+"); 153 sectionPositions.add(0); 154 filteredList.add(new CityObj(mSelectedCitiesHeaderString, 155 mSelectedCitiesHeaderString, 156 null)); 157 } 158 for (CityObj city : mSelectedCities) { 159 filteredList.add(city); 160 } 161 } 162 163 mSelectedEndPosition = filteredList.size(); 164 165 long currentTime = System.currentTimeMillis(); 166 String val = null; 167 int offset = -100000; //some value that cannot be a real offset 168 for (CityObj city : mCities) { 169 170 // If the city is a deleted entry, ignore it. 171 if (city.mCityId.equals(DELETED_ENTRY)) { 172 continue; 173 } 174 175 // If the search query is empty, add section headers. 176 if (TextUtils.isEmpty(modifiedQuery)) { 177 178 179 // If the list is sorted by name, and the city begins with a letter 180 // different than the previous city's letter, insert a section header. 181 if (mSortType == SORT_BY_NAME 182 && !city.mCityName.substring(0, 1).equals(val)) { 183 val = city.mCityName.substring(0, 1).toUpperCase(); 184 sectionHeaders.add(val); 185 sectionPositions.add(filteredList.size()); 186 filteredList.add(new CityObj(val, null, null)); 187 } 188 189 // If the list is sorted by time, and the gmt offset is different than 190 // the previous city's gmt offset, insert a section header. 191 if (mSortType == SORT_BY_GMT_OFFSET) { 192 TimeZone timezone = TimeZone.getTimeZone(city.mTimeZone); 193 int newOffset = timezone.getOffset(currentTime); 194 if (offset != newOffset) { 195 offset = newOffset; 196 String offsetString = Utils.getGMTHourOffset(timezone, true); 197 sectionHeaders.add(offsetString); 198 sectionPositions.add(filteredList.size()); 199 filteredList.add(new CityObj(null, offsetString, null)); 200 } 201 } 202 } 203 204 // If the city name begins with the query, add the city into the list. 205 // If the query is empty, the city will automatically be added to the list. 206 String cityName = city.mCityName.trim().toUpperCase(); 207 if (city.mCityId != null && cityName.startsWith(modifiedQuery)) { 208 filteredList.add(city); 209 } 210 } 211 212 mSectionHeaders = sectionHeaders.toArray(new String[sectionHeaders.size()]); 213 mSectionPositions = sectionPositions.toArray(new Integer[sectionPositions.size()]); 214 215 results.values = filteredList; 216 results.count = filteredList.size(); 217 return results; 218 } 219 220 @Override 221 protected void publishResults(CharSequence constraint, FilterResults results) { 222 mDisplayedCitiesList = (ArrayList<CityObj>) results.values; 223 if (mPosition >= 0) { 224 mCitiesList.setSelectionFromTop(mPosition, 0); 225 mPosition = -1; 226 } 227 notifyDataSetChanged(); 228 } 229 }; 230 231 public CityAdapter( 232 Context context, LayoutInflater factory) { 233 super(); 234 mCalendar = Calendar.getInstance(); 235 mCalendar.setTimeInMillis(System.currentTimeMillis()); 236 mLayoutDirection = TextUtils.getLayoutDirectionFromLocale(Locale.getDefault()); 237 mInflater = factory; 238 239 // Load the cities from xml. 240 mCities = Utils.loadCitiesFromXml(context); 241 242 // Reload the city name map with the recently parsed city names of the currently 243 // selected language for use with selected cities. 244 mCityNameMap.clear(); 245 for (CityObj city : mCities) { 246 mCityNameMap.put(city.mCityId, city.mCityName); 247 } 248 249 // Re-organize the selected cities into an array. 250 Collection<CityObj> selectedCities = mUserSelectedCities.values(); 251 mSelectedCities = selectedCities.toArray(new CityObj[selectedCities.size()]); 252 253 // Override the selected city names in the shared preferences with the 254 // city names in the updated city name map, which will always reflect the 255 // current language. 256 for (CityObj city : mSelectedCities) { 257 String newCityName = mCityNameMap.get(city.mCityId); 258 if (newCityName != null) { 259 city.mCityName = newCityName; 260 } 261 } 262 263 mPattern24 = DateFormat.getBestDateTimePattern(Locale.getDefault(), "Hm"); 264 265 // There's an RTL layout bug that causes jank when fast-scrolling through 266 // the list in 12-hour mode in an RTL locale. We can work around this by 267 // ensuring the strings are the same length by using "hh" instead of "h". 268 String pattern12 = DateFormat.getBestDateTimePattern(Locale.getDefault(), "hma"); 269 if (mLayoutDirection == View.LAYOUT_DIRECTION_RTL) { 270 pattern12 = pattern12.replaceAll("h", "hh"); 271 } 272 mPattern12 = pattern12; 273 274 sortCities(mSortType); 275 set24HoursMode(context); 276 } 277 278 public void refreshSelectedCities() { 279 Collection<CityObj> selectedCities = mUserSelectedCities.values(); 280 mSelectedCities = selectedCities.toArray(new CityObj[selectedCities.size()]); 281 sortCities(mSortType); 282 } 283 284 public void toggleSort() { 285 if (mSortType == SORT_BY_NAME) { 286 sortCities(SORT_BY_GMT_OFFSET); 287 } else { 288 sortCities(SORT_BY_NAME); 289 } 290 } 291 292 private void sortCities(final int sortType) { 293 mSortType = sortType; 294 Arrays.sort(mCities, sortType == SORT_BY_NAME ? mSortByNameComparator 295 : mSortByTimeComparator); 296 if (mSelectedCities != null) { 297 Arrays.sort(mSelectedCities, sortType == SORT_BY_NAME ? mSortByNameComparator 298 : mSortByTimeComparator); 299 } 300 mPrefs.edit().putInt(PREF_SORT, sortType).commit(); 301 mFilter.filter(mQueryTextBuffer.toString()); 302 } 303 304 @Override 305 public int getCount() { 306 return mDisplayedCitiesList != null ? mDisplayedCitiesList.size() : 0; 307 } 308 309 @Override 310 public Object getItem(int p) { 311 if (mDisplayedCitiesList != null && p >= 0 && p < mDisplayedCitiesList.size()) { 312 return mDisplayedCitiesList.get(p); 313 } 314 return null; 315 } 316 317 @Override 318 public long getItemId(int p) { 319 return p; 320 } 321 322 @Override 323 public boolean isEnabled(int p) { 324 return mDisplayedCitiesList != null && mDisplayedCitiesList.get(p).mCityId != null; 325 } 326 327 @Override 328 public synchronized View getView(int position, View view, ViewGroup parent) { 329 if (mDisplayedCitiesList == null || position < 0 330 || position >= mDisplayedCitiesList.size()) { 331 return null; 332 } 333 CityObj c = mDisplayedCitiesList.get(position); 334 // Header view: A CityObj with nothing but the first letter as the name 335 if (c.mCityId == null) { 336 if (view == null) { 337 view = mInflater.inflate(R.layout.city_list_header, parent, false); 338 view.setTag(view.findViewById(R.id.header)); 339 } 340 ((TextView) view.getTag()).setText( 341 mSortType == SORT_BY_NAME ? c.mCityName : c.mTimeZone); 342 } else { // City view 343 // Make sure to recycle a City view only 344 if (view == null) { 345 view = mInflater.inflate(R.layout.city_list_item, parent, false); 346 final CityViewHolder holder = new CityViewHolder(); 347 holder.name = (TextView) view.findViewById(R.id.city_name); 348 holder.time = (TextView) view.findViewById(R.id.city_time); 349 holder.selected = (CheckBox) view.findViewById(R.id.city_onoff); 350 holder.selectedPin = (ImageView) view.findViewById(R.id.city_selected_icon); 351 holder.remove = (ImageView) view.findViewById(R.id.city_remove); 352 holder.remove.setOnClickListener(new OnClickListener() { 353 354 @Override 355 public void onClick(View view) { 356 CompoundButton b = holder.selected; 357 onCheckedChanged(b, false); 358 b.setChecked(false); 359 mAdapter.refreshSelectedCities(); 360 } 361 }); 362 view.setTag(holder); 363 } 364 view.setOnClickListener(CitiesActivity.this); 365 CityViewHolder holder = (CityViewHolder) view.getTag(); 366 367 if (position < mSelectedEndPosition) { 368 holder.selected.setVisibility(View.GONE); 369 holder.time.setVisibility(View.GONE); 370 holder.remove.setVisibility(View.VISIBLE); 371 holder.selectedPin.setVisibility(View.VISIBLE); 372 view.setEnabled(false); 373 } else { 374 holder.selected.setVisibility(View.VISIBLE); 375 holder.time.setVisibility(View.VISIBLE); 376 holder.remove.setVisibility(View.GONE); 377 holder.selectedPin.setVisibility(View.GONE); 378 view.setEnabled(true); 379 } 380 holder.selected.setTag(c); 381 holder.selected.setChecked(mUserSelectedCities.containsKey(c.mCityId)); 382 holder.selected.setOnCheckedChangeListener(CitiesActivity.this); 383 holder.name.setText(c.mCityName, TextView.BufferType.SPANNABLE); 384 holder.time.setText(getTimeCharSequence(c.mTimeZone)); 385 } 386 return view; 387 } 388 389 private CharSequence getTimeCharSequence(String timeZone) { 390 mCalendar.setTimeZone(TimeZone.getTimeZone(timeZone)); 391 return DateFormat.format(mIs24HoursMode ? mPattern24 : mPattern12, mCalendar); 392 } 393 394 @Override 395 public int getViewTypeCount() { 396 return 2; 397 } 398 399 @Override 400 public int getItemViewType(int position) { 401 return (mDisplayedCitiesList.get(position).mCityId != null) 402 ? VIEW_TYPE_CITY : VIEW_TYPE_HEADER; 403 } 404 405 private class CityViewHolder { 406 TextView name; 407 TextView time; 408 CheckBox selected; 409 ImageView selectedPin; 410 ImageView remove; 411 } 412 413 public void set24HoursMode(Context c) { 414 mIs24HoursMode = DateFormat.is24HourFormat(c); 415 notifyDataSetChanged(); 416 } 417 418 @Override 419 public int getPositionForSection(int section) { 420 return !isEmpty(mSectionPositions) ? mSectionPositions[section] : 0; 421 } 422 423 424 @Override 425 public int getSectionForPosition(int p) { 426 final Integer[] positions = mSectionPositions; 427 if (!isEmpty(positions)) { 428 for (int i = 0; i < positions.length - 1; i++) { 429 if (p >= positions[i] 430 && p < positions[i + 1]) { 431 return i; 432 } 433 } 434 if (p >= positions[positions.length - 1]) { 435 return positions.length - 1; 436 } 437 } 438 return 0; 439 } 440 441 @Override 442 public Object[] getSections() { 443 return mSectionHeaders; 444 } 445 446 @Override 447 public Filter getFilter() { 448 return mFilter; 449 } 450 451 private boolean isEmpty(Object[] array) { 452 return array == null || array.length == 0; 453 } 454 } 455 456 @Override 457 protected void onCreate(Bundle savedInstanceState) { 458 super.onCreate(savedInstanceState); 459 mFactory = LayoutInflater.from(this); 460 mPrefs = PreferenceManager.getDefaultSharedPreferences(this); 461 mSortType = mPrefs.getInt(PREF_SORT, SORT_BY_NAME); 462 mSelectedCitiesHeaderString = getString(R.string.selected_cities_label); 463 if (savedInstanceState != null) { 464 mQueryTextBuffer.append(savedInstanceState.getString(KEY_SEARCH_QUERY)); 465 mSearchMode = savedInstanceState.getBoolean(KEY_SEARCH_MODE); 466 mPosition = savedInstanceState.getInt(KEY_LIST_POSITION); 467 } 468 updateLayout(); 469 } 470 471 @Override 472 public void onSaveInstanceState(Bundle bundle) { 473 super.onSaveInstanceState(bundle); 474 bundle.putString(KEY_SEARCH_QUERY, mQueryTextBuffer.toString()); 475 bundle.putBoolean(KEY_SEARCH_MODE, mSearchMode); 476 bundle.putInt(KEY_LIST_POSITION, mCitiesList.getFirstVisiblePosition()); 477 } 478 479 private void updateLayout() { 480 setContentView(R.layout.cities_activity); 481 mCitiesList = (ListView) findViewById(R.id.cities_list); 482 setFastScroll(TextUtils.isEmpty(mQueryTextBuffer.toString().trim())); 483 mCitiesList.setScrollBarStyle(View.SCROLLBARS_INSIDE_INSET); 484 mCitiesList.setFastScrollEnabled(true); 485 mUserSelectedCities = Cities.readCitiesFromSharedPrefs( 486 PreferenceManager.getDefaultSharedPreferences(this)); 487 mAdapter = new CityAdapter(this, mFactory); 488 mCitiesList.setAdapter(mAdapter); 489 ActionBar actionBar = getActionBar(); 490 if (actionBar != null) { 491 actionBar.setDisplayOptions(ActionBar.DISPLAY_HOME_AS_UP, ActionBar.DISPLAY_HOME_AS_UP); 492 } 493 } 494 495 private void setFastScroll(boolean enabled) { 496 if (mCitiesList != null) { 497 mCitiesList.setFastScrollAlwaysVisible(enabled); 498 mCitiesList.setFastScrollEnabled(enabled); 499 } 500 } 501 502 @Override 503 public void onResume() { 504 super.onResume(); 505 if (mAdapter != null) { 506 mAdapter.set24HoursMode(this); 507 } 508 } 509 510 @Override 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 520 public boolean onOptionsItemSelected(MenuItem item) { 521 switch (item.getItemId()) { 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 case android.R.id.home: 542 Intent intent = new Intent(this, DeskClock.class); 543 intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); 544 startActivity(intent); 545 return true; 546 default: 547 break; 548 } 549 return super.onOptionsItemSelected(item); 550 } 551 552 @Override 553 public boolean onCreateOptionsMenu(Menu menu) { 554 getMenuInflater().inflate(R.menu.cities_menu, menu); 555 MenuItem help = menu.findItem(R.id.menu_item_help); 556 if (help != null) { 557 Utils.prepareHelpMenuItem(this, help); 558 } 559 560 MenuItem searchMenu = menu.findItem(R.id.menu_item_search); 561 mSearchView = (SearchView) searchMenu.getActionView(); 562 mSearchView.setImeOptions(EditorInfo.IME_FLAG_NO_EXTRACT_UI); 563 mSearchView.setOnSearchClickListener(new OnClickListener() { 564 565 @Override 566 public void onClick(View arg0) { 567 mSearchMode = true; 568 } 569 }); 570 mSearchView.setOnCloseListener(new SearchView.OnCloseListener() { 571 572 @Override 573 public boolean onClose() { 574 mSearchMode = false; 575 return false; 576 } 577 }); 578 if (mSearchView != null) { 579 mSearchView.setOnQueryTextListener(this); 580 mSearchView.setQuery(mQueryTextBuffer.toString(), false); 581 if (mSearchMode) { 582 mSearchView.requestFocus(); 583 mSearchView.setIconified(false); 584 } 585 } 586 return super.onCreateOptionsMenu(menu); 587 } 588 589 @Override 590 public boolean onPrepareOptionsMenu(Menu menu) { 591 MenuItem sortMenuItem = menu.findItem(R.id.menu_item_sort); 592 if (mSortType == SORT_BY_NAME) { 593 sortMenuItem.setTitle(getString(R.string.menu_item_sort_by_gmt_offset)); 594 } else { 595 sortMenuItem.setTitle(getString(R.string.menu_item_sort_by_name)); 596 } 597 return super.onPrepareOptionsMenu(menu); 598 } 599 600 @Override 601 public void onCheckedChanged(CompoundButton b, boolean checked) { 602 CityObj c = (CityObj) b.getTag(); 603 if (checked) { 604 mUserSelectedCities.put(c.mCityId, c); 605 } else { 606 mUserSelectedCities.remove(c.mCityId); 607 } 608 } 609 610 @Override 611 public void onClick(View v) { 612 CompoundButton b = (CompoundButton) v.findViewById(R.id.city_onoff); 613 boolean checked = b.isChecked(); 614 onCheckedChanged(b, checked); 615 b.setChecked(!checked); 616 mAdapter.refreshSelectedCities(); 617 } 618 619 @Override 620 public boolean onQueryTextChange(String queryText) { 621 mQueryTextBuffer.setLength(0); 622 mQueryTextBuffer.append(queryText); 623 mCitiesList.setFastScrollEnabled(TextUtils.isEmpty(mQueryTextBuffer.toString().trim())); 624 mAdapter.getFilter().filter(queryText); 625 return true; 626 } 627 628 @Override 629 public boolean onQueryTextSubmit(String arg0) { 630 return false; 631 } 632 } 633