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