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