Home | History | Annotate | Download | only in worldclock
      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
     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
    123     public void onSaveInstanceState(Bundle bundle) {
    124         super.onSaveInstanceState(bundle);
    125         mSearchMenuItemController.saveInstance(bundle);
    126     }
    127 
    128     @Override
    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
    137     public void onPause() {
    138         super.onPause();
    139 
    140         // Save the selected cities.
    141         DataModel.getDataModel().setSelectedCities(mCitiesAdapter.getSelectedCities());
    142     }
    143 
    144     @Override
    145     public boolean onCreateOptionsMenu(Menu menu) {
    146         mActionBarMenuManager.createOptionsMenu(menu, getMenuInflater());
    147         return true;
    148     }
    149 
    150     @Override
    151     public boolean onPrepareOptionsMenu(Menu menu) {
    152         mActionBarMenuManager.prepareShowMenu(menu);
    153         return true;
    154     }
    155 
    156     @Override
    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      */
    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 
    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
    265         public int getCount() {
    266             final int headerCount = hasHeader() ? 1 : 0;
    267             return headerCount + mFilteredCities.size();
    268         }
    269 
    270         @Override
    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
    287         public long getItemId(int position) {
    288             return position;
    289         }
    290 
    291         @Override
    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
    350         public int getViewTypeCount() {
    351             return 2;
    352         }
    353 
    354         @Override
    355         public int getItemViewType(int position) {
    356             return hasHeader() && position == 0 ? VIEW_TYPE_SELECTED_CITIES_HEADER : VIEW_TYPE_CITY;
    357         }
    358 
    359         @Override
    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
    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
    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
    417         public int getPositionForSection(int sectionIndex) {
    418             return getSections().length == 0 ? 0 : mSectionHeaderPositions[sectionIndex];
    419         }
    420 
    421         @Override
    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          */
    440         private void clearSectionHeaders() {
    441             mSectionHeaders = null;
    442             mSectionHeaderPositions = null;
    443         }
    444 
    445         /**
    446          * Rebuilds all internal data structures from scratch.
    447          */
    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          */
    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 
    491         private boolean isFiltering() {
    492             return !TextUtils.isEmpty(mSearchMenuItemController.getQueryText().trim());
    493         }
    494 
    495         private Collection<City> getSelectedCities() { return mUserSelectedCities; }
    496         private boolean hasHeader() { return !isFiltering() && mOriginalUserSelectionCount > 0; }
    497 
    498         private DataModel.CitySort getCitySort() {
    499             return DataModel.getDataModel().getCitySort();
    500         }
    501 
    502         private Comparator<City> getCitySortComparator() {
    503             return DataModel.getDataModel().getCityIndexComparator();
    504         }
    505 
    506         private CharSequence getTimeCharSequence(TimeZone timeZone) {
    507             mCalendar.setTimeZone(timeZone);
    508             return DateFormat.format(mIs24HoursMode ? mPattern24 : mPattern12, mCalendar);
    509         }
    510 
    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 
    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
    569         public int getId() {
    570             return SORT_MENU_RES_ID;
    571         }
    572 
    573         @Override
    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
    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