/* * Copyright (C) 2020 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.deskclock.worldclock import android.content.Context import android.os.Bundle import androidx.appcompat.widget.SearchView import android.text.TextUtils import android.text.format.DateFormat import android.util.ArraySet import android.util.TypedValue import android.view.LayoutInflater import android.view.Menu import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.BaseAdapter import android.widget.CheckBox import android.widget.CompoundButton import android.widget.ListView import android.widget.SectionIndexer import android.widget.TextView import com.android.deskclock.BaseActivity import com.android.deskclock.DropShadowController import com.android.deskclock.R import com.android.deskclock.Utils import com.android.deskclock.actionbarmenu.MenuItemController import com.android.deskclock.actionbarmenu.MenuItemControllerFactory import com.android.deskclock.actionbarmenu.NavUpMenuItemController import com.android.deskclock.actionbarmenu.OptionsMenuManager import com.android.deskclock.actionbarmenu.SearchMenuItemController import com.android.deskclock.actionbarmenu.SettingsMenuItemController import com.android.deskclock.data.City import com.android.deskclock.data.DataModel import java.util.ArrayList import java.util.Calendar import java.util.Comparator import java.util.Locale import java.util.TimeZone /** * This activity allows the user to alter the cities selected for display. * * Note, it is possible for two instances of this Activity to exist simultaneously: * * * As a result, [.onResume] conservatively refreshes itself from the backing * [DataModel] which may have changed since this activity was last displayed. */ class CitySelectionActivity : BaseActivity() { /** * The list of all selected and unselected cities, indexed and possibly filtered. */ private lateinit var mCitiesList: ListView /** * The adapter that presents all of the selected and unselected cities. */ private lateinit var mCitiesAdapter: CityAdapter /** * Manages all action bar menu display and click handling. */ private val mOptionsMenuManager = OptionsMenuManager() /** * Menu item controller for search view. */ private lateinit var mSearchMenuItemController: SearchMenuItemController /** * The controller that shows the drop shadow when content is not scrolled to the top. */ private lateinit var mDropShadowController: DropShadowController override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.cities_activity) mSearchMenuItemController = SearchMenuItemController( getSupportActionBar()!!.getThemedContext(), object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { return false } override fun onQueryTextChange(query: String): Boolean { mCitiesAdapter.filter(query) updateFastScrolling() return true } }, savedInstanceState) mCitiesAdapter = CityAdapter(this, mSearchMenuItemController) mOptionsMenuManager.addMenuItemController(NavUpMenuItemController(this)) .addMenuItemController(mSearchMenuItemController) .addMenuItemController(SortOrderMenuItemController()) .addMenuItemController(SettingsMenuItemController(this)) .addMenuItemController(*MenuItemControllerFactory.buildMenuItemControllers(this)) mCitiesList = findViewById(R.id.cities_list) as ListView mCitiesList.adapter = mCitiesAdapter updateFastScrolling() } override fun onSaveInstanceState(bundle: Bundle) { super.onSaveInstanceState(bundle) mSearchMenuItemController.saveInstance(bundle) } override fun onResume() { super.onResume() // Recompute the contents of the adapter before displaying on screen. mCitiesAdapter.refresh() val dropShadow: View = findViewById(R.id.drop_shadow) mDropShadowController = DropShadowController(dropShadow, mCitiesList) } override fun onPause() { super.onPause() mDropShadowController.stop() // Save the selected cities. DataModel.dataModel.selectedCities = mCitiesAdapter.selectedCities } override fun onCreateOptionsMenu(menu: Menu): Boolean { mOptionsMenuManager.onCreateOptionsMenu(menu) return true } override fun onPrepareOptionsMenu(menu: Menu): Boolean { mOptionsMenuManager.onPrepareOptionsMenu(menu) return true } override fun onOptionsItemSelected(item: MenuItem): Boolean { return (mOptionsMenuManager.onOptionsItemSelected(item) || super.onOptionsItemSelected(item)) } /** * Fast scrolling is only enabled while no filtering is happening. */ private fun updateFastScrolling() { val enabled: Boolean = !mCitiesAdapter.isFiltering mCitiesList.isFastScrollAlwaysVisible = enabled mCitiesList.isFastScrollEnabled = enabled } /** * This adapter presents data in 2 possible modes. If selected cities exist the format is: * *
     * Selected Cities
     * City 1 (alphabetically first)
     * City 2 (alphabetically second)
     * ...
     * A City A1 (alphabetically first starting with A)
     * City A2 (alphabetically second starting with A)
     * ...
     * B City B1 (alphabetically first starting with B)
     * City B2 (alphabetically second starting with B)
     * ...
     * 
* * If selected cities do not exist, that section is removed and all that remains is: * *
     * A City A1 (alphabetically first starting with A)
     * City A2 (alphabetically second starting with A)
     * ...
     * B City B1 (alphabetically first starting with B)
     * City B2 (alphabetically second starting with B)
     * ...
     * 
*/ private class CityAdapter( private val mContext: Context, /** Menu item controller for search. Search query is maintained here. */ private val mSearchMenuItemController: SearchMenuItemController ) : BaseAdapter(), View.OnClickListener, CompoundButton.OnCheckedChangeListener, SectionIndexer { private val mInflater: LayoutInflater = LayoutInflater.from(mContext) /** * The 12-hour time pattern for the current locale. */ private val mPattern12: String /** * The 24-hour time pattern for the current locale. */ private val mPattern24: String /** * `true` time should honor [.mPattern24]; [.mPattern12] otherwise. */ private var mIs24HoursMode = false /** * A calendar used to format time in a particular timezone. */ private val mCalendar: Calendar = Calendar.getInstance() /** * The list of cities which may be filtered by a search term. */ private var mFilteredCities: List = emptyList() /** * A mutable set of cities currently selected by the user. */ private val mUserSelectedCities: MutableSet = ArraySet() /** * The number of user selections at the top of the adapter to avoid indexing. */ private var mOriginalUserSelectionCount = 0 /** * The precomputed section headers. */ private var mSectionHeaders: Array? = null /** * The corresponding location of each precomputed section header. */ private var mSectionHeaderPositions: Array? = null init { mCalendar.timeInMillis = System.currentTimeMillis() val locale = Locale.getDefault() mPattern24 = DateFormat.getBestDateTimePattern(locale, "Hm") var pattern12 = DateFormat.getBestDateTimePattern(locale, "hma") if (TextUtils.getLayoutDirectionFromLocale(locale) == View.LAYOUT_DIRECTION_RTL) { // There's an RTL layout bug that causes jank when fast-scrolling through // the list in 12-hour mode in an RTL locale. We can work around this by // ensuring the strings are the same length by using "hh" instead of "h". pattern12 = pattern12.replace("h".toRegex(), "hh") } mPattern12 = pattern12 } override fun getCount(): Int { val headerCount = if (hasHeader()) 1 else 0 return headerCount + mFilteredCities.size } override fun getItem(position: Int): City? { if (hasHeader()) { val itemViewType = getItemViewType(position) when (itemViewType) { VIEW_TYPE_SELECTED_CITIES_HEADER -> return null VIEW_TYPE_CITY -> return mFilteredCities[position - 1] } throw IllegalStateException("unexpected item view type: $itemViewType") } return mFilteredCities[position] } override fun getItemId(position: Int): Long { return position.toLong() } override fun getView(position: Int, view: View?, parent: ViewGroup): View { var variableView = view val itemViewType = getItemViewType(position) when (itemViewType) { VIEW_TYPE_SELECTED_CITIES_HEADER -> { return variableView ?: mInflater.inflate(R.layout.city_list_header, parent, false) } VIEW_TYPE_CITY -> { val city = getItem(position) ?: throw IllegalStateException("The desired city does not exist") val timeZone: TimeZone = city.timeZone // Inflate a new view if necessary. if (variableView == null) { variableView = mInflater.inflate(R.layout.city_list_item, parent, false) val index = variableView.findViewById(R.id.index) as TextView val name = variableView.findViewById(R.id.city_name) as TextView val time = variableView.findViewById(R.id.city_time) as TextView val selected = variableView.findViewById(R.id.city_onoff) as CheckBox variableView.tag = CityItemHolder(index, name, time, selected) } // Bind data into the child views. val holder = variableView!!.tag as CityItemHolder holder.selected.tag = city holder.selected.isChecked = mUserSelectedCities.contains(city) holder.selected.contentDescription = city.name holder.selected.setOnCheckedChangeListener(this) holder.name.setText(city.name, TextView.BufferType.SPANNABLE) holder.time.text = getTimeCharSequence(timeZone) val showIndex = getShowIndex(position) holder.index.visibility = if (showIndex) View.VISIBLE else View.INVISIBLE if (showIndex) { when (citySort) { DataModel.CitySort.NAME -> { holder.index.setText(city.indexString) holder.index.setTextSize(TypedValue.COMPLEX_UNIT_SP, 24f) } DataModel.CitySort.UTC_OFFSET -> { holder.index.text = Utils.getGMTHourOffset(timeZone, false) holder.index.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) } } } // skip checkbox and other animations variableView.jumpDrawablesToCurrentState() variableView.setOnClickListener(this) return variableView } else -> throw IllegalStateException("unexpected item view type: $itemViewType") } } override fun getViewTypeCount(): Int { return 2 } override fun getItemViewType(position: Int): Int { return if (hasHeader() && position == 0) { VIEW_TYPE_SELECTED_CITIES_HEADER } else { VIEW_TYPE_CITY } } override fun onCheckedChanged(b: CompoundButton, checked: Boolean) { val city = b.tag as City if (checked) { mUserSelectedCities.add(city) b.announceForAccessibility(mContext.getString(R.string.city_checked, city.name)) } else { mUserSelectedCities.remove(city) b.announceForAccessibility(mContext.getString(R.string.city_unchecked, city.name)) } } override fun onClick(v: View) { val b = v.findViewById(R.id.city_onoff) as CheckBox b.isChecked = !b.isChecked } override fun getSections(): Array? { if (mSectionHeaders == null) { // Make an educated guess at the expected number of sections. val approximateSectionCount = count / 5 val sections: MutableList = ArrayList(approximateSectionCount) val positions: MutableList = ArrayList(approximateSectionCount) // Add a section for the "Selected Cities" header if it exists. if (hasHeader()) { sections.add("+") positions.add(0) } for (position in 0 until count) { // Add a section if this position should show the section index. if (getShowIndex(position)) { val city = getItem(position) ?: throw IllegalStateException("The desired city does not exist") when (citySort) { DataModel.CitySort.NAME -> sections.add(city.indexString.orEmpty()) DataModel.CitySort.UTC_OFFSET -> { val timezone: TimeZone = city.timeZone sections.add(Utils.getGMTHourOffset(timezone, Utils.isPreL)) } } positions.add(position) } } mSectionHeaders = sections.toTypedArray() mSectionHeaderPositions = positions.toTypedArray() } return mSectionHeaders } override fun getPositionForSection(sectionIndex: Int): Int { return if (sections!!.isEmpty()) 0 else mSectionHeaderPositions!![sectionIndex] } override fun getSectionForPosition(position: Int): Int { if (sections!!.isEmpty()) { return 0 } for (i in 0 until mSectionHeaderPositions!!.size - 2) { if (position < mSectionHeaderPositions!![i]) continue if (position >= mSectionHeaderPositions!![i + 1]) continue return i } return mSectionHeaderPositions!!.size - 1 } /** * Clear the section headers to force them to be recomputed if they are now stale. */ fun clearSectionHeaders() { mSectionHeaders = null mSectionHeaderPositions = null } /** * Rebuilds all internal data structures from scratch. */ fun refresh() { // Update the 12/24 hour mode. mIs24HoursMode = DateFormat.is24HourFormat(mContext) // Refresh the user selections. val selected = DataModel.dataModel.selectedCities as List mUserSelectedCities.clear() mUserSelectedCities.addAll(selected) mOriginalUserSelectionCount = selected.size // Recompute section headers. clearSectionHeaders() // Recompute filtered cities. filter(mSearchMenuItemController.queryText) } /** * Filter the cities using the given `queryText`. */ fun filter(queryText: String) { mSearchMenuItemController.queryText = queryText val query = City.removeSpecialCharacters(queryText.toUpperCase()) // Compute the filtered list of cities. val filteredCities = if (TextUtils.isEmpty(query)) { DataModel.dataModel.allCities } else { val unselected: List = DataModel.dataModel.unselectedCities val queriedCities: MutableList = ArrayList(unselected.size) for (city in unselected) { if (city.matches(query)) { queriedCities.add(city) } } queriedCities } // Swap in the filtered list of cities and notify of the data change. mFilteredCities = filteredCities notifyDataSetChanged() } val isFiltering: Boolean get() = !TextUtils.isEmpty(mSearchMenuItemController.queryText.trim({ it <= ' ' })) val selectedCities: Collection get() = mUserSelectedCities private fun hasHeader(): Boolean { return !isFiltering && mOriginalUserSelectionCount > 0 } private val citySort: DataModel.CitySort get() = DataModel.dataModel.citySort private val citySortComparator: Comparator get() = DataModel.dataModel.cityIndexComparator private fun getTimeCharSequence(timeZone: TimeZone): CharSequence { mCalendar.timeZone = timeZone return DateFormat.format(if (mIs24HoursMode) mPattern24 else mPattern12, mCalendar) } private fun getShowIndex(position: Int): Boolean { // Indexes are never displayed on filtered cities. if (isFiltering) { return false } if (hasHeader()) { // None of the original user selections should show their index. if (position <= mOriginalUserSelectionCount) { return false } // The first item after the original user selections must always show its index. if (position == mOriginalUserSelectionCount + 1) { return true } } else { // None of the original user selections should show their index. if (position < mOriginalUserSelectionCount) { return false } // The first item after the original user selections must always show its index. if (position == mOriginalUserSelectionCount) { return true } } // Otherwise compare the city with its predecessor to test if it is a header. val priorCity = getItem(position - 1) val city = getItem(position) return citySortComparator.compare(priorCity, city) != 0 } /** * Cache the child views of each city item view. */ private class CityItemHolder( val index: TextView, val name: TextView, val time: TextView, val selected: CheckBox ) companion object { /** * The type of the single optional "Selected Cities" header entry. */ private const val VIEW_TYPE_SELECTED_CITIES_HEADER = 0 /** * The type of each city entry. */ private const val VIEW_TYPE_CITY = 1 } } private inner class SortOrderMenuItemController : MenuItemController { private val SORT_MENU_RES_ID = R.id.menu_item_sort override val id: Int get() = SORT_MENU_RES_ID override fun onCreateOptionsItem(menu: Menu) { menu.add(Menu.NONE, R.id.menu_item_sort, Menu.NONE, R.string.menu_item_sort_by_gmt_offset) .setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER) } override fun onPrepareOptionsItem(item: MenuItem) { item.setTitle(if (DataModel.dataModel.citySort == DataModel.CitySort.NAME) { R.string.menu_item_sort_by_gmt_offset } else { R.string.menu_item_sort_by_name }) } override fun onOptionsItemSelected(item: MenuItem): Boolean { // Save the new sort order. DataModel.dataModel.toggleCitySort() // Section headers are influenced by sort order and must be cleared. mCitiesAdapter.clearSectionHeaders() // Honor the new sort order in the adapter. mCitiesAdapter.filter(mSearchMenuItemController.queryText) return true } } }