• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 }