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