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.content.ActivityNotFoundException; 20 import android.content.Context; 21 import android.content.Intent; 22 import android.content.SharedPreferences; 23 import android.media.AudioManager; 24 import android.os.Build; 25 import android.os.Bundle; 26 import android.preference.PreferenceManager; 27 import android.support.v4.view.MenuItemCompat; 28 import android.support.v7.widget.SearchView; 29 import android.text.TextUtils; 30 import android.text.format.DateFormat; 31 import android.util.TypedValue; 32 import android.view.LayoutInflater; 33 import android.view.Menu; 34 import android.view.MenuItem; 35 import android.view.View; 36 import android.view.View.OnClickListener; 37 import android.view.ViewGroup; 38 import android.view.inputmethod.EditorInfo; 39 import android.widget.BaseAdapter; 40 import android.widget.CheckBox; 41 import android.widget.CompoundButton; 42 import android.widget.CompoundButton.OnCheckedChangeListener; 43 import android.widget.Filter; 44 import android.widget.Filterable; 45 import android.widget.ListView; 46 import android.widget.SectionIndexer; 47 import android.widget.TextView; 48 49 import com.android.deskclock.BaseActivity; 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 BaseActivity implements OnCheckedChangeListener, 68 View.OnClickListener, SearchView.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<>(); 147 ArrayList<String> sectionHeaders = new ArrayList<>(); 148 ArrayList<Integer> sectionPositions = new ArrayList<>(); 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, null, 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 has an index 187 // different than the previous city's index, update the section header. 188 if (mSortType == SORT_BY_NAME 189 && !city.mCityIndex.equals(val)) { 190 val = city.mCityIndex.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 // Because JB fastscroll only supports ~1 char strings 206 // and KK ellipsizes strings, trim section headers to the 207 // nearest hour. 208 final String offsetString = Utils.getGMTHourOffset(timezone, 209 Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT 210 /* useShortForm */ ); 211 sectionHeaders.add(offsetString); 212 sectionPositions.add(filteredList.size()); 213 city.isHeader = true; 214 } else { 215 city.isHeader = false; 216 } 217 } 218 219 filteredList.add(city); 220 } 221 } else { 222 // If the city name begins with the non-empty query, add it into the list. 223 String cityName = city.mCityName.trim().toUpperCase(); 224 if (city.mCityId != null && cityName.startsWith(modifiedQuery)) { 225 city.isHeader = false; 226 filteredList.add(city); 227 } 228 } 229 } 230 231 mSectionHeaders = sectionHeaders.toArray(new String[sectionHeaders.size()]); 232 mSectionPositions = sectionPositions.toArray(new Integer[sectionPositions.size()]); 233 234 results.values = filteredList; 235 results.count = filteredList.size(); 236 return results; 237 } 238 239 @Override 240 protected void publishResults(CharSequence constraint, FilterResults results) { 241 mDisplayedCitiesList = (ArrayList<CityObj>) results.values; 242 if (mPosition >= 0) { 243 mCitiesList.setSelectionFromTop(mPosition, 0); 244 mPosition = -1; 245 } 246 notifyDataSetChanged(); 247 } 248 }; 249 CityAdapter( Context context, LayoutInflater factory)250 public CityAdapter( 251 Context context, LayoutInflater factory) { 252 super(); 253 mCalendar = Calendar.getInstance(); 254 mCalendar.setTimeInMillis(System.currentTimeMillis()); 255 mLayoutDirection = TextUtils.getLayoutDirectionFromLocale(Locale.getDefault()); 256 mInflater = factory; 257 258 // Load the cities from xml. 259 mCities = Utils.loadCitiesFromXml(context); 260 261 // Reload the city name map with the recently parsed city names of the currently 262 // selected language for use with selected cities. 263 mCityNameMap.clear(); 264 for (CityObj city : mCities) { 265 mCityNameMap.put(city.mCityId, city.mCityName); 266 } 267 268 // Re-organize the selected cities into an array. 269 Collection<CityObj> selectedCities = mUserSelectedCities.values(); 270 mSelectedCities = selectedCities.toArray(new CityObj[selectedCities.size()]); 271 272 // Override the selected city names in the shared preferences with the 273 // city names in the updated city name map, which will always reflect the 274 // current language. 275 for (CityObj city : mSelectedCities) { 276 String newCityName = mCityNameMap.get(city.mCityId); 277 if (newCityName != null) { 278 city.mCityName = newCityName; 279 } 280 } 281 282 mPattern24 = Utils.isJBMR2OrLater() 283 ? DateFormat.getBestDateTimePattern(Locale.getDefault(), "Hm") 284 : getString(R.string.time_format_24_mode); 285 286 // There's an RTL layout bug that causes jank when fast-scrolling through 287 // the list in 12-hour mode in an RTL locale. We can work around this by 288 // ensuring the strings are the same length by using "hh" instead of "h". 289 String pattern12 = Utils.isJBMR2OrLater() 290 ? DateFormat.getBestDateTimePattern(Locale.getDefault(), "hma") 291 : getString(R.string.time_format_12_mode); 292 293 if (mLayoutDirection == View.LAYOUT_DIRECTION_RTL) { 294 pattern12 = pattern12.replaceAll("h", "hh"); 295 } 296 mPattern12 = pattern12; 297 298 sortCities(mSortType); 299 set24HoursMode(context); 300 } 301 toggleSort()302 public void toggleSort() { 303 if (mSortType == SORT_BY_NAME) { 304 sortCities(SORT_BY_GMT_OFFSET); 305 } else { 306 sortCities(SORT_BY_NAME); 307 } 308 } 309 sortCities(final int sortType)310 private void sortCities(final int sortType) { 311 mSortType = sortType; 312 Arrays.sort(mCities, sortType == SORT_BY_NAME ? mSortByNameComparator 313 : mSortByTimeComparator); 314 if (mSelectedCities != null) { 315 Arrays.sort(mSelectedCities, sortType == SORT_BY_NAME ? mSortByNameComparator 316 : mSortByTimeComparator); 317 } 318 mPrefs.edit().putInt(PREF_SORT, sortType).commit(); 319 mFilter.filter(mQueryTextBuffer.toString()); 320 } 321 322 @Override getCount()323 public int getCount() { 324 return mDisplayedCitiesList != null ? mDisplayedCitiesList.size() : 0; 325 } 326 327 @Override getItem(int p)328 public Object getItem(int p) { 329 if (mDisplayedCitiesList != null && p >= 0 && p < mDisplayedCitiesList.size()) { 330 return mDisplayedCitiesList.get(p); 331 } 332 return null; 333 } 334 335 @Override getItemId(int p)336 public long getItemId(int p) { 337 return p; 338 } 339 340 @Override isEnabled(int p)341 public boolean isEnabled(int p) { 342 return mDisplayedCitiesList != null && mDisplayedCitiesList.get(p).mCityId != null; 343 } 344 345 @Override getView(int position, View view, ViewGroup parent)346 public synchronized View getView(int position, View view, ViewGroup parent) { 347 if (mDisplayedCitiesList == null || position < 0 348 || position >= mDisplayedCitiesList.size()) { 349 return null; 350 } 351 CityObj c = mDisplayedCitiesList.get(position); 352 // Header view: A CityObj with nothing but the "selected cities" label 353 if (c.mCityId == null) { 354 if (view == null) { 355 view = mInflater.inflate(R.layout.city_list_header, parent, false); 356 } 357 } else { // City view 358 // Make sure to recycle a City view only 359 if (view == null) { 360 view = mInflater.inflate(R.layout.city_list_item, parent, false); 361 final CityViewHolder holder = new CityViewHolder(); 362 holder.index = (TextView) view.findViewById(R.id.index); 363 holder.name = (TextView) view.findViewById(R.id.city_name); 364 holder.time = (TextView) view.findViewById(R.id.city_time); 365 holder.selected = (CheckBox) view.findViewById(R.id.city_onoff); 366 view.setTag(holder); 367 } 368 view.setOnClickListener(CitiesActivity.this); 369 CityViewHolder holder = (CityViewHolder) view.getTag(); 370 371 holder.selected.setTag(c); 372 holder.selected.setChecked(mUserSelectedCities.containsKey(c.mCityId)); 373 holder.selected.setOnCheckedChangeListener(CitiesActivity.this); 374 holder.name.setText(c.mCityName, TextView.BufferType.SPANNABLE); 375 holder.time.setText(getTimeCharSequence(c.mTimeZone)); 376 if (c.isHeader) { 377 holder.index.setVisibility(View.VISIBLE); 378 if (mSortType == SORT_BY_NAME) { 379 holder.index.setText(c.mCityIndex); 380 holder.index.setTextSize(TypedValue.COMPLEX_UNIT_SP, 24); 381 } else { // SORT_BY_GMT_OFFSET 382 holder.index.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14); 383 holder.index.setText(Utils.getGMTHourOffset( 384 TimeZone.getTimeZone(c.mTimeZone), false)); 385 } 386 } else { 387 // If not a header, use the invisible index for left padding 388 holder.index.setVisibility(View.INVISIBLE); 389 } 390 // skip checkbox and other animations 391 view.jumpDrawablesToCurrentState(); 392 } 393 return view; 394 } 395 getTimeCharSequence(String timeZone)396 private CharSequence getTimeCharSequence(String timeZone) { 397 mCalendar.setTimeZone(TimeZone.getTimeZone(timeZone)); 398 return DateFormat.format(mIs24HoursMode ? mPattern24 : mPattern12, mCalendar); 399 } 400 401 @Override getViewTypeCount()402 public int getViewTypeCount() { 403 return 2; 404 } 405 406 @Override getItemViewType(int position)407 public int getItemViewType(int position) { 408 return (mDisplayedCitiesList.get(position).mCityId != null) 409 ? VIEW_TYPE_CITY : VIEW_TYPE_HEADER; 410 } 411 412 private class CityViewHolder { 413 TextView index; 414 TextView name; 415 TextView time; 416 CheckBox selected; 417 } 418 set24HoursMode(Context c)419 public void set24HoursMode(Context c) { 420 mIs24HoursMode = DateFormat.is24HourFormat(c); 421 notifyDataSetChanged(); 422 } 423 424 @Override getPositionForSection(int section)425 public int getPositionForSection(int section) { 426 return !isEmpty(mSectionPositions) ? mSectionPositions[section] : 0; 427 } 428 429 430 @Override getSectionForPosition(int p)431 public int getSectionForPosition(int p) { 432 final Integer[] positions = mSectionPositions; 433 if (!isEmpty(positions)) { 434 for (int i = 0; i < positions.length - 1; i++) { 435 if (p >= positions[i] 436 && p < positions[i + 1]) { 437 return i; 438 } 439 } 440 if (p >= positions[positions.length - 1]) { 441 return positions.length - 1; 442 } 443 } 444 return 0; 445 } 446 447 @Override getSections()448 public Object[] getSections() { 449 return mSectionHeaders; 450 } 451 452 @Override getFilter()453 public Filter getFilter() { 454 return mFilter; 455 } 456 isEmpty(Object[] array)457 private boolean isEmpty(Object[] array) { 458 return array == null || array.length == 0; 459 } 460 } 461 462 @Override onCreate(Bundle savedInstanceState)463 protected void onCreate(Bundle savedInstanceState) { 464 super.onCreate(savedInstanceState); 465 setVolumeControlStream(AudioManager.STREAM_ALARM); 466 467 mFactory = LayoutInflater.from(this); 468 mPrefs = PreferenceManager.getDefaultSharedPreferences(this); 469 mSortType = mPrefs.getInt(PREF_SORT, SORT_BY_NAME); 470 mSelectedCitiesHeaderString = getString(R.string.selected_cities_label); 471 if (savedInstanceState != null) { 472 mQueryTextBuffer.append(savedInstanceState.getString(KEY_SEARCH_QUERY)); 473 mSearchMode = savedInstanceState.getBoolean(KEY_SEARCH_MODE); 474 mPosition = savedInstanceState.getInt(KEY_LIST_POSITION); 475 } 476 updateLayout(); 477 } 478 479 @Override onSaveInstanceState(Bundle bundle)480 public void onSaveInstanceState(Bundle bundle) { 481 super.onSaveInstanceState(bundle); 482 bundle.putString(KEY_SEARCH_QUERY, mQueryTextBuffer.toString()); 483 bundle.putBoolean(KEY_SEARCH_MODE, mSearchMode); 484 bundle.putInt(KEY_LIST_POSITION, mCitiesList.getFirstVisiblePosition()); 485 } 486 updateLayout()487 private void updateLayout() { 488 setContentView(R.layout.cities_activity); 489 mCitiesList = (ListView) findViewById(R.id.cities_list); 490 setFastScroll(TextUtils.isEmpty(mQueryTextBuffer.toString().trim())); 491 mCitiesList.setScrollBarStyle(View.SCROLLBARS_INSIDE_INSET); 492 mUserSelectedCities = Cities.readCitiesFromSharedPrefs( 493 PreferenceManager.getDefaultSharedPreferences(this)); 494 mAdapter = new CityAdapter(this, mFactory); 495 mCitiesList.setAdapter(mAdapter); 496 } 497 setFastScroll(boolean enabled)498 private void setFastScroll(boolean enabled) { 499 if (mCitiesList != null) { 500 mCitiesList.setFastScrollAlwaysVisible(enabled); 501 mCitiesList.setFastScrollEnabled(enabled); 502 } 503 } 504 505 @Override onResume()506 public void onResume() { 507 super.onResume(); 508 if (mAdapter != null) { 509 mAdapter.set24HoursMode(this); 510 } 511 } 512 513 @Override onPause()514 public void onPause() { 515 super.onPause(); 516 Cities.saveCitiesToSharedPrefs(PreferenceManager.getDefaultSharedPreferences(this), 517 mUserSelectedCities); 518 Intent i = new Intent(Cities.WORLDCLOCK_UPDATE_INTENT); 519 sendBroadcast(i); 520 } 521 522 @Override onOptionsItemSelected(MenuItem item)523 public boolean onOptionsItemSelected(MenuItem item) { 524 switch (item.getItemId()) { 525 case android.R.id.home: 526 finish(); 527 return true; 528 case R.id.menu_item_settings: 529 startActivity(new Intent(this, SettingsActivity.class)); 530 return true; 531 case R.id.menu_item_help: 532 Intent i = item.getIntent(); 533 if (i != null) { 534 try { 535 startActivity(i); 536 } catch (ActivityNotFoundException e) { 537 // No activity found to match the intent - ignore 538 } 539 } 540 return true; 541 case R.id.menu_item_sort: 542 if (mAdapter != null) { 543 mAdapter.toggleSort(); 544 setFastScroll(TextUtils.isEmpty(mQueryTextBuffer.toString().trim())); 545 } 546 return true; 547 default: 548 break; 549 } 550 return super.onOptionsItemSelected(item); 551 } 552 553 @Override onCreateOptionsMenu(Menu menu)554 public boolean onCreateOptionsMenu(Menu menu) { 555 getMenuInflater().inflate(R.menu.cities_menu, menu); 556 MenuItem help = menu.findItem(R.id.menu_item_help); 557 if (help != null) { 558 Utils.prepareHelpMenuItem(this, help); 559 } 560 561 MenuItem searchMenu = menu.findItem(R.id.menu_item_search); 562 mSearchView = (SearchView) MenuItemCompat.getActionView(searchMenu); 563 mSearchView.setImeOptions(EditorInfo.IME_FLAG_NO_EXTRACT_UI); 564 mSearchView.setOnSearchClickListener(new OnClickListener() { 565 566 @Override 567 public void onClick(View arg0) { 568 mSearchMode = true; 569 } 570 }); 571 mSearchView.setOnCloseListener(new SearchView.OnCloseListener() { 572 573 @Override 574 public boolean onClose() { 575 mSearchMode = false; 576 return false; 577 } 578 }); 579 if (mSearchView != null) { 580 mSearchView.setOnQueryTextListener(this); 581 mSearchView.setQuery(mQueryTextBuffer.toString(), false); 582 if (mSearchMode) { 583 mSearchView.requestFocus(); 584 mSearchView.setIconified(false); 585 } 586 } 587 return super.onCreateOptionsMenu(menu); 588 } 589 590 @Override onPrepareOptionsMenu(Menu menu)591 public boolean onPrepareOptionsMenu(Menu menu) { 592 MenuItem sortMenuItem = menu.findItem(R.id.menu_item_sort); 593 if (mSortType == SORT_BY_NAME) { 594 sortMenuItem.setTitle(getString(R.string.menu_item_sort_by_gmt_offset)); 595 } else { 596 sortMenuItem.setTitle(getString(R.string.menu_item_sort_by_name)); 597 } 598 return super.onPrepareOptionsMenu(menu); 599 } 600 601 @Override onCheckedChanged(CompoundButton b, boolean checked)602 public void onCheckedChanged(CompoundButton b, boolean checked) { 603 CityObj c = (CityObj) b.getTag(); 604 if (checked) { 605 mUserSelectedCities.put(c.mCityId, c); 606 } else { 607 mUserSelectedCities.remove(c.mCityId); 608 } 609 } 610 611 @Override onClick(View v)612 public void onClick(View v) { 613 CompoundButton b = (CompoundButton) v.findViewById(R.id.city_onoff); 614 boolean checked = b.isChecked(); 615 onCheckedChanged(b, checked); 616 b.setChecked(!checked); 617 } 618 619 @Override onQueryTextChange(String queryText)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 onQueryTextSubmit(String arg0)629 public boolean onQueryTextSubmit(String arg0) { 630 return false; 631 } 632 } 633