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