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