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