Home | History | Annotate | Download | only in worldclock
      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 android.support.v7.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
    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
    137     public void onSaveInstanceState(Bundle bundle) {
    138         super.onSaveInstanceState(bundle);
    139         mSearchMenuItemController.saveInstance(bundle);
    140     }
    141 
    142     @Override
    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
    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
    164     public boolean onCreateOptionsMenu(Menu menu) {
    165         mOptionsMenuManager.onCreateOptionsMenu(menu);
    166         return true;
    167     }
    168 
    169     @Override
    170     public boolean onPrepareOptionsMenu(Menu menu) {
    171         mOptionsMenuManager.onPrepareOptionsMenu(menu);
    172         return true;
    173     }
    174 
    175     @Override
    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      */
    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 
    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
    306         public int getCount() {
    307             final int headerCount = hasHeader() ? 1 : 0;
    308             return headerCount + mFilteredCities.size();
    309         }
    310 
    311         @Override
    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
    328         public long getItemId(int position) {
    329             return position;
    330         }
    331 
    332         @Override
    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
    394         public int getViewTypeCount() {
    395             return 2;
    396         }
    397 
    398         @Override
    399         public int getItemViewType(int position) {
    400             return hasHeader() && position == 0 ? VIEW_TYPE_SELECTED_CITIES_HEADER : VIEW_TYPE_CITY;
    401         }
    402 
    403         @Override
    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
    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
    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
    464         public int getPositionForSection(int sectionIndex) {
    465             return getSections().length == 0 ? 0 : mSectionHeaderPositions[sectionIndex];
    466         }
    467 
    468         @Override
    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          */
    487         private void clearSectionHeaders() {
    488             mSectionHeaders = null;
    489             mSectionHeaderPositions = null;
    490         }
    491 
    492         /**
    493          * Rebuilds all internal data structures from scratch.
    494          */
    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          */
    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 
    538         private boolean isFiltering() {
    539             return !TextUtils.isEmpty(mSearchMenuItemController.getQueryText().trim());
    540         }
    541 
    542         private Collection<City> getSelectedCities() {
    543             return mUserSelectedCities;
    544         }
    545 
    546         private boolean hasHeader() {
    547             return !isFiltering() && mOriginalUserSelectionCount > 0;
    548         }
    549 
    550         private DataModel.CitySort getCitySort() {
    551             return DataModel.getDataModel().getCitySort();
    552         }
    553 
    554         private Comparator<City> getCitySortComparator() {
    555             return DataModel.getDataModel().getCityIndexComparator();
    556         }
    557 
    558         private CharSequence getTimeCharSequence(TimeZone timeZone) {
    559             mCalendar.setTimeZone(timeZone);
    560             return DateFormat.format(mIs24HoursMode ? mPattern24 : mPattern12, mCalendar);
    561         }
    562 
    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 
    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
    621         public int getId() {
    622             return SORT_MENU_RES_ID;
    623         }
    624 
    625         @Override
    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
    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
    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