CitySelectionActivity.java revision 4e7ee09e878178873241846757b178535da3dd76
1/* 2 * Copyright (C) 2015 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 * use this file except in compliance with the License. You may obtain a copy of 6 * 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, WITHOUT 12 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 * License for the specific language governing permissions and limitations under 14 * the License. 15 */ 16 17package com.android.deskclock.worldclock; 18 19import android.content.Context; 20import android.os.Bundle; 21import android.support.v7.widget.SearchView; 22import android.text.TextUtils; 23import android.text.format.DateFormat; 24import android.util.ArraySet; 25import android.util.TypedValue; 26import android.view.LayoutInflater; 27import android.view.Menu; 28import android.view.MenuItem; 29import android.view.View; 30import android.view.ViewGroup; 31import android.widget.BaseAdapter; 32import android.widget.CheckBox; 33import android.widget.CompoundButton; 34import android.widget.ListView; 35import android.widget.SectionIndexer; 36import android.widget.TextView; 37 38import com.android.deskclock.BaseActivity; 39import com.android.deskclock.DropShadowController; 40import com.android.deskclock.R; 41import com.android.deskclock.Utils; 42import com.android.deskclock.actionbarmenu.AbstractMenuItemController; 43import com.android.deskclock.actionbarmenu.ActionBarMenuManager; 44import com.android.deskclock.actionbarmenu.MenuItemControllerFactory; 45import com.android.deskclock.actionbarmenu.NavUpMenuItemController; 46import com.android.deskclock.actionbarmenu.SearchMenuItemController; 47import com.android.deskclock.actionbarmenu.SettingMenuItemController; 48import com.android.deskclock.data.City; 49import com.android.deskclock.data.DataModel; 50 51import java.util.ArrayList; 52import java.util.Calendar; 53import java.util.Collection; 54import java.util.Collections; 55import java.util.Comparator; 56import java.util.List; 57import java.util.Locale; 58import java.util.Set; 59import java.util.TimeZone; 60 61/** 62 * This activity allows the user to alter the cities selected for display. 63 * 64 * Note, it is possible for two instances of this Activity to exist simultaneously: 65 * 66 * <ul> 67 * <li>Clock Tab-> Tap Floating Action Button</li> 68 * <li>Digital Widget -> Tap any city clock</li> 69 * </ul> 70 * 71 * As a result, {@link #onResume()} conservatively refreshes itself from the backing 72 * {@link DataModel} which may have changed since this activity was last displayed. 73 */ 74public final class CitySelectionActivity extends BaseActivity { 75 76 /** The list of all selected and unselected cities, indexed and possibly filtered. */ 77 private ListView mCitiesList; 78 79 /** The adapter that presents all of the selected and unselected cities. */ 80 private CityAdapter mCitiesAdapter; 81 82 /** Manages all action bar menu display and click handling. */ 83 private final ActionBarMenuManager mActionBarMenuManager = new ActionBarMenuManager(); 84 85 /** Menu item controller for search view. */ 86 private SearchMenuItemController mSearchMenuItemController; 87 88 /** The controller that shows the drop shadow when content is not scrolled to the top. */ 89 private DropShadowController mDropShadowController; 90 91 @Override 92 protected void onCreate(Bundle savedInstanceState) { 93 super.onCreate(savedInstanceState); 94 95 setContentView(R.layout.cities_activity); 96 mSearchMenuItemController = 97 new SearchMenuItemController(new SearchView.OnQueryTextListener() { 98 @Override 99 public boolean onQueryTextSubmit(String query) { 100 return false; 101 } 102 103 @Override 104 public boolean onQueryTextChange(String query) { 105 mCitiesAdapter.filter(query); 106 updateFastScrolling(); 107 return true; 108 } 109 }, savedInstanceState); 110 mCitiesAdapter = new CityAdapter(this, mSearchMenuItemController); 111 mActionBarMenuManager.addMenuItemController(new NavUpMenuItemController(this)) 112 .addMenuItemController(mSearchMenuItemController) 113 .addMenuItemController(new SortOrderMenuItemController()) 114 .addMenuItemController(new SettingMenuItemController(this)) 115 .addMenuItemController(MenuItemControllerFactory.getInstance() 116 .buildMenuItemControllers(this)); 117 mCitiesList = (ListView) findViewById(R.id.cities_list); 118 mCitiesList.setScrollBarStyle(View.SCROLLBARS_INSIDE_INSET); 119 mCitiesList.setAdapter(mCitiesAdapter); 120 121 updateFastScrolling(); 122 } 123 124 @Override 125 public void onSaveInstanceState(Bundle bundle) { 126 super.onSaveInstanceState(bundle); 127 mSearchMenuItemController.saveInstance(bundle); 128 } 129 130 @Override 131 public void onResume() { 132 super.onResume(); 133 134 // Recompute the contents of the adapter before displaying on screen. 135 mCitiesAdapter.refresh(); 136 137 final View dropShadow = findViewById(R.id.drop_shadow); 138 mDropShadowController = new DropShadowController(dropShadow, mCitiesList); 139 } 140 141 @Override 142 public void onPause() { 143 super.onPause(); 144 145 mDropShadowController.stop(); 146 147 // Save the selected cities. 148 DataModel.getDataModel().setSelectedCities(mCitiesAdapter.getSelectedCities()); 149 } 150 151 @Override 152 public boolean onCreateOptionsMenu(Menu menu) { 153 mActionBarMenuManager.createOptionsMenu(menu, getMenuInflater()); 154 return true; 155 } 156 157 @Override 158 public boolean onPrepareOptionsMenu(Menu menu) { 159 mActionBarMenuManager.prepareShowMenu(menu); 160 return true; 161 } 162 163 @Override 164 public boolean onOptionsItemSelected(MenuItem item) { 165 if (mActionBarMenuManager.handleMenuItemClick(item)) { 166 return true; 167 } 168 return super.onOptionsItemSelected(item); 169 } 170 171 /** 172 * Fast scrolling is only enabled while no filtering is happening. 173 */ 174 private void updateFastScrolling() { 175 final boolean enabled = !mCitiesAdapter.isFiltering(); 176 mCitiesList.setFastScrollAlwaysVisible(enabled); 177 mCitiesList.setFastScrollEnabled(enabled); 178 } 179 180 /** 181 * This adapter presents data in 2 possible modes. If selected cities exist the format is: 182 * 183 * <pre> 184 * Selected Cities 185 * City 1 (alphabetically first) 186 * City 2 (alphabetically second) 187 * ... 188 * A City A1 (alphabetically first starting with A) 189 * City A2 (alphabetically second starting with A) 190 * ... 191 * B City B1 (alphabetically first starting with B) 192 * City B2 (alphabetically second starting with B) 193 * ... 194 * </pre> 195 * 196 * If selected cities do not exist, that section is removed and all that remains is: 197 * 198 * <pre> 199 * A City A1 (alphabetically first starting with A) 200 * City A2 (alphabetically second starting with A) 201 * ... 202 * B City B1 (alphabetically first starting with B) 203 * City B2 (alphabetically second starting with B) 204 * ... 205 * </pre> 206 */ 207 private static final class CityAdapter extends BaseAdapter implements View.OnClickListener, 208 CompoundButton.OnCheckedChangeListener, SectionIndexer { 209 210 /** The type of the single optional "Selected Cities" header entry. */ 211 private static final int VIEW_TYPE_SELECTED_CITIES_HEADER = 0; 212 213 /** The type of each city entry. */ 214 private static final int VIEW_TYPE_CITY = 1; 215 216 private final Context mContext; 217 218 private final LayoutInflater mInflater; 219 220 /** The 12-hour time pattern for the current locale. */ 221 private final String mPattern12; 222 223 /** The 24-hour time pattern for the current locale. */ 224 private final String mPattern24; 225 226 /** {@code true} time should honor {@link #mPattern24}; {@link #mPattern12} otherwise. */ 227 private boolean mIs24HoursMode; 228 229 /** A calendar used to format time in a particular timezone. */ 230 private final Calendar mCalendar; 231 232 /** The list of cities which may be filtered by a search term. */ 233 private List<City> mFilteredCities = Collections.emptyList(); 234 235 /** A mutable set of cities currently selected by the user. */ 236 private final Set<City> mUserSelectedCities = new ArraySet<>(); 237 238 /** The number of user selections at the top of the adapter to avoid indexing. */ 239 private int mOriginalUserSelectionCount; 240 241 /** The precomputed section headers. */ 242 private String[] mSectionHeaders; 243 244 /** The corresponding location of each precomputed section header. */ 245 private Integer[] mSectionHeaderPositions; 246 247 /** Menu item controller for search. Search query is maintained here. */ 248 private final SearchMenuItemController mSearchMenuItemController; 249 250 public CityAdapter(Context context, SearchMenuItemController searchMenuItemController) { 251 mContext = context; 252 mSearchMenuItemController = searchMenuItemController; 253 mInflater = LayoutInflater.from(context); 254 255 mCalendar = Calendar.getInstance(); 256 mCalendar.setTimeInMillis(System.currentTimeMillis()); 257 258 final Locale locale = Locale.getDefault(); 259 mPattern24 = DateFormat.getBestDateTimePattern(locale, "Hm"); 260 261 String pattern12 = DateFormat.getBestDateTimePattern(locale, "hma"); 262 if (TextUtils.getLayoutDirectionFromLocale(locale) == View.LAYOUT_DIRECTION_RTL) { 263 // There's an RTL layout bug that causes jank when fast-scrolling through 264 // the list in 12-hour mode in an RTL locale. We can work around this by 265 // ensuring the strings are the same length by using "hh" instead of "h". 266 pattern12 = pattern12.replaceAll("h", "hh"); 267 } 268 mPattern12 = pattern12; 269 } 270 271 @Override 272 public int getCount() { 273 final int headerCount = hasHeader() ? 1 : 0; 274 return headerCount + mFilteredCities.size(); 275 } 276 277 @Override 278 public City getItem(int position) { 279 if (hasHeader()) { 280 final int itemViewType = getItemViewType(position); 281 switch (itemViewType) { 282 case VIEW_TYPE_SELECTED_CITIES_HEADER: 283 return null; 284 case VIEW_TYPE_CITY: 285 return mFilteredCities.get(position - 1); 286 } 287 throw new IllegalStateException("unexpected item view type: " + itemViewType); 288 } 289 290 return mFilteredCities.get(position); 291 } 292 293 @Override 294 public long getItemId(int position) { 295 return position; 296 } 297 298 @Override 299 public synchronized View getView(int position, View view, ViewGroup parent) { 300 final int itemViewType = getItemViewType(position); 301 switch (itemViewType) { 302 case VIEW_TYPE_SELECTED_CITIES_HEADER: 303 if (view == null) { 304 view = mInflater.inflate(R.layout.city_list_header, parent, false); 305 } 306 return view; 307 308 case VIEW_TYPE_CITY: 309 final City city = getItem(position); 310 final TimeZone timeZone = city.getTimeZone(); 311 312 // Inflate a new view if necessary. 313 if (view == null) { 314 view = mInflater.inflate(R.layout.city_list_item, parent, false); 315 final TextView index = (TextView) view.findViewById(R.id.index); 316 final TextView name = (TextView) view.findViewById(R.id.city_name); 317 final TextView time = (TextView) view.findViewById(R.id.city_time); 318 final CheckBox selected = (CheckBox) view.findViewById(R.id.city_onoff); 319 view.setTag(new CityItemHolder(index, name, time, selected)); 320 } 321 322 // Bind data into the child views. 323 final CityItemHolder holder = (CityItemHolder) view.getTag(); 324 holder.selected.setTag(city); 325 holder.selected.setChecked(mUserSelectedCities.contains(city)); 326 holder.selected.setContentDescription(city.getName()); 327 holder.selected.setOnCheckedChangeListener(this); 328 holder.name.setText(city.getName(), TextView.BufferType.SPANNABLE); 329 holder.time.setText(getTimeCharSequence(timeZone)); 330 331 final boolean showIndex = getShowIndex(position); 332 holder.index.setVisibility(showIndex ? View.VISIBLE : View.INVISIBLE); 333 if (showIndex) { 334 switch (getCitySort()) { 335 case NAME: 336 holder.index.setText(city.getIndexString()); 337 holder.index.setTextSize(TypedValue.COMPLEX_UNIT_SP, 24); 338 break; 339 340 case UTC_OFFSET: 341 holder.index.setText(Utils.getGMTHourOffset(timeZone, false)); 342 holder.index.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14); 343 break; 344 } 345 } 346 347 // skip checkbox and other animations 348 view.jumpDrawablesToCurrentState(); 349 view.setOnClickListener(this); 350 return view; 351 } 352 353 throw new IllegalStateException("unexpected item view type: " + itemViewType); 354 } 355 356 @Override 357 public int getViewTypeCount() { 358 return 2; 359 } 360 361 @Override 362 public int getItemViewType(int position) { 363 return hasHeader() && position == 0 ? VIEW_TYPE_SELECTED_CITIES_HEADER : VIEW_TYPE_CITY; 364 } 365 366 @Override 367 public void onCheckedChanged(CompoundButton b, boolean checked) { 368 final City city = (City) b.getTag(); 369 if (checked) { 370 mUserSelectedCities.add(city); 371 b.announceForAccessibility(mContext.getString(R.string.city_checked, 372 city.getName())); 373 } else { 374 mUserSelectedCities.remove(city); 375 b.announceForAccessibility(mContext.getString(R.string.city_unchecked, 376 city.getName())); 377 } 378 } 379 380 @Override 381 public void onClick(View v) { 382 final CheckBox b = (CheckBox) v.findViewById(R.id.city_onoff); 383 b.setChecked(!b.isChecked()); 384 } 385 386 @Override 387 public Object[] getSections() { 388 if (mSectionHeaders == null) { 389 // Make an educated guess at the expected number of sections. 390 final int approximateSectionCount = getCount() / 5; 391 final List<String> sections = new ArrayList<>(approximateSectionCount); 392 final List<Integer> positions = new ArrayList<>(approximateSectionCount); 393 394 // Add a section for the "Selected Cities" header if it exists. 395 if (hasHeader()) { 396 sections.add("+"); 397 positions.add(0); 398 } 399 400 for (int position = 0; position < getCount(); position++) { 401 // Add a section if this position should show the section index. 402 if (getShowIndex(position)) { 403 final City city = getItem(position); 404 switch (getCitySort()) { 405 case NAME: 406 sections.add(city.getIndexString()); 407 break; 408 case UTC_OFFSET: 409 final TimeZone timezone = city.getTimeZone(); 410 sections.add(Utils.getGMTHourOffset(timezone, Utils.isPreL())); 411 break; 412 } 413 positions.add(position); 414 } 415 } 416 417 mSectionHeaders = sections.toArray(new String[sections.size()]); 418 mSectionHeaderPositions = positions.toArray(new Integer[positions.size()]); 419 } 420 return mSectionHeaders; 421 } 422 423 @Override 424 public int getPositionForSection(int sectionIndex) { 425 return getSections().length == 0 ? 0 : mSectionHeaderPositions[sectionIndex]; 426 } 427 428 @Override 429 public int getSectionForPosition(int position) { 430 if (getSections().length == 0) { 431 return 0; 432 } 433 434 for (int i = 0; i < mSectionHeaderPositions.length - 2; i++) { 435 if (position < mSectionHeaderPositions[i]) continue; 436 if (position >= mSectionHeaderPositions[i + 1]) continue; 437 438 return i; 439 } 440 441 return mSectionHeaderPositions.length - 1; 442 } 443 444 /** 445 * Clear the section headers to force them to be recomputed if they are now stale. 446 */ 447 private void clearSectionHeaders() { 448 mSectionHeaders = null; 449 mSectionHeaderPositions = null; 450 } 451 452 /** 453 * Rebuilds all internal data structures from scratch. 454 */ 455 private void refresh() { 456 // Update the 12/24 hour mode. 457 mIs24HoursMode = DateFormat.is24HourFormat(mContext); 458 459 // Refresh the user selections. 460 final List<City> selected = DataModel.getDataModel().getSelectedCities(); 461 mUserSelectedCities.clear(); 462 mUserSelectedCities.addAll(selected); 463 mOriginalUserSelectionCount = selected.size(); 464 465 // Recompute section headers. 466 clearSectionHeaders(); 467 468 // Recompute filtered cities. 469 filter(mSearchMenuItemController.getQueryText()); 470 } 471 472 /** 473 * Filter the cities using the given {@code queryText}. 474 */ 475 private void filter(String queryText) { 476 mSearchMenuItemController.setQueryText(queryText); 477 final String query = City.removeSpecialCharacters(queryText.toUpperCase()); 478 479 // Compute the filtered list of cities. 480 final List<City> filteredCities; 481 if (TextUtils.isEmpty(query)) { 482 filteredCities = DataModel.getDataModel().getAllCities(); 483 } else { 484 final List<City> unselected = DataModel.getDataModel().getUnselectedCities(); 485 filteredCities = new ArrayList<>(unselected.size()); 486 for (City city : unselected) { 487 if (city.matches(query)) { 488 filteredCities.add(city); 489 } 490 } 491 } 492 493 // Swap in the filtered list of cities and notify of the data change. 494 mFilteredCities = filteredCities; 495 notifyDataSetChanged(); 496 } 497 498 private boolean isFiltering() { 499 return !TextUtils.isEmpty(mSearchMenuItemController.getQueryText().trim()); 500 } 501 502 private Collection<City> getSelectedCities() { return mUserSelectedCities; } 503 private boolean hasHeader() { return !isFiltering() && mOriginalUserSelectionCount > 0; } 504 505 private DataModel.CitySort getCitySort() { 506 return DataModel.getDataModel().getCitySort(); 507 } 508 509 private Comparator<City> getCitySortComparator() { 510 return DataModel.getDataModel().getCityIndexComparator(); 511 } 512 513 private CharSequence getTimeCharSequence(TimeZone timeZone) { 514 mCalendar.setTimeZone(timeZone); 515 return DateFormat.format(mIs24HoursMode ? mPattern24 : mPattern12, mCalendar); 516 } 517 518 private boolean getShowIndex(int position) { 519 // Indexes are never displayed on filtered cities. 520 if (isFiltering()) { 521 return false; 522 } 523 524 if (hasHeader()) { 525 // None of the original user selections should show their index. 526 if (position <= mOriginalUserSelectionCount) { 527 return false; 528 } 529 530 // The first item after the original user selections must always show its index. 531 if (position == mOriginalUserSelectionCount + 1) { 532 return true; 533 } 534 } else { 535 // None of the original user selections should show their index. 536 if (position < mOriginalUserSelectionCount) { 537 return false; 538 } 539 540 // The first item after the original user selections must always show its index. 541 if (position == mOriginalUserSelectionCount) { 542 return true; 543 } 544 } 545 546 // Otherwise compare the city with its predecessor to test if it is a header. 547 final City priorCity = getItem(position - 1); 548 final City city = getItem(position); 549 return getCitySortComparator().compare(priorCity, city) != 0; 550 } 551 552 /** 553 * Cache the child views of each city item view. 554 */ 555 private static final class CityItemHolder { 556 557 private final TextView index; 558 private final TextView name; 559 private final TextView time; 560 private final CheckBox selected; 561 562 public CityItemHolder(TextView index, TextView name, TextView time, CheckBox selected) { 563 this.index = index; 564 this.name = name; 565 this.time = time; 566 this.selected = selected; 567 } 568 } 569 } 570 571 private final class SortOrderMenuItemController extends AbstractMenuItemController { 572 573 private static final int SORT_MENU_RES_ID = R.id.menu_item_sort; 574 575 @Override 576 public int getId() { 577 return SORT_MENU_RES_ID; 578 } 579 580 @Override 581 public void showMenuItem(Menu menu) { 582 final MenuItem sortMenuItem = menu.findItem(SORT_MENU_RES_ID); 583 final String title; 584 if (DataModel.getDataModel().getCitySort() == DataModel.CitySort.NAME) { 585 title = getString(R.string.menu_item_sort_by_gmt_offset); 586 } else { 587 title = getString(R.string.menu_item_sort_by_name); 588 } 589 sortMenuItem.setTitle(title); 590 sortMenuItem.setVisible(true); 591 } 592 593 @Override 594 public boolean handleMenuItemClick(MenuItem item) { 595 // Save the new sort order. 596 DataModel.getDataModel().toggleCitySort(); 597 598 // Section headers are influenced by sort order and must be cleared. 599 mCitiesAdapter.clearSectionHeaders(); 600 601 // Honor the new sort order in the adapter. 602 mCitiesAdapter.filter(mSearchMenuItemController.getQueryText()); 603 return true; 604 } 605 } 606} 607