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