| /* |
| * 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: |
| * <ul> |
| * <li>Clock Tab-> Tap Floating Action Button</li> |
| * <li>Digital Widget -> Tap any city clock</li> |
| * </ul> |
| * |
| * 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.getInstance() |
| .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: |
| * |
| * <pre> |
| * 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) |
| * ... |
| * </pre> |
| * |
| * If selected cities do not exist, that section is removed and all that remains is: |
| * |
| * <pre> |
| * 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) |
| * ... |
| * </pre> |
| */ |
| 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<City> = emptyList() |
| |
| /** |
| * A mutable set of cities currently selected by the user. |
| */ |
| private val mUserSelectedCities: MutableSet<City> = 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<String>? = null |
| |
| /** |
| * The corresponding location of each precomputed section header. |
| */ |
| private var mSectionHeaderPositions: Array<Int>? = 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<View>(R.id.index) as TextView |
| val name = variableView.findViewById<View>(R.id.city_name) as TextView |
| val time = variableView.findViewById<View>(R.id.city_time) as TextView |
| val selected = variableView.findViewById<View>(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<View>(R.id.city_onoff) as CheckBox |
| b.isChecked = !b.isChecked |
| } |
| |
| override fun getSections(): Array<String>? { |
| if (mSectionHeaders == null) { |
| // Make an educated guess at the expected number of sections. |
| val approximateSectionCount = count / 5 |
| val sections: MutableList<String> = ArrayList(approximateSectionCount) |
| val positions: MutableList<Int> = 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<City> |
| 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<City> = DataModel.dataModel.unselectedCities |
| val queriedCities: MutableList<City> = 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<City> |
| get() = mUserSelectedCities |
| |
| private fun hasHeader(): Boolean { |
| return !isFiltering && mOriginalUserSelectionCount > 0 |
| } |
| |
| private val citySort: DataModel.CitySort |
| get() = DataModel.dataModel.citySort |
| |
| private val citySortComparator: Comparator<City> |
| 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 |
| } |
| } |
| } |