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