1/*
2 * Copyright (C) 2012 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
17package com.android.deskclock.worldclock;
18
19import android.app.ActionBar;
20import android.app.Activity;
21import android.content.ActivityNotFoundException;
22import android.content.Context;
23import android.content.Intent;
24import android.content.SharedPreferences;
25import android.os.Bundle;
26import android.preference.PreferenceManager;
27import android.text.TextUtils;
28import android.text.format.DateFormat;
29import android.util.TypedValue;
30import android.view.LayoutInflater;
31import android.view.Menu;
32import android.view.MenuItem;
33import android.view.View;
34import android.view.View.OnClickListener;
35import android.view.ViewGroup;
36import android.view.inputmethod.EditorInfo;
37import android.widget.BaseAdapter;
38import android.widget.CheckBox;
39import android.widget.CompoundButton;
40import android.widget.CompoundButton.OnCheckedChangeListener;
41import android.widget.Filter;
42import android.widget.Filterable;
43import android.widget.ListView;
44import android.widget.SearchView;
45import android.widget.SearchView.OnQueryTextListener;
46import android.widget.SectionIndexer;
47import android.widget.TextView;
48
49import com.android.deskclock.R;
50import com.android.deskclock.SettingsActivity;
51import com.android.deskclock.Utils;
52
53import java.util.ArrayList;
54import java.util.Arrays;
55import java.util.Calendar;
56import java.util.Collection;
57import java.util.HashMap;
58import java.util.HashSet;
59import java.util.List;
60import java.util.Locale;
61import java.util.TimeZone;
62
63/**
64 * Cities chooser for the world clock
65 */
66public class CitiesActivity extends Activity implements OnCheckedChangeListener,
67        View.OnClickListener, OnQueryTextListener {
68
69    private static final String KEY_SEARCH_QUERY = "search_query";
70    private static final String KEY_SEARCH_MODE = "search_mode";
71    private static final String KEY_LIST_POSITION = "list_position";
72
73    private static final String PREF_SORT = "sort_preference";
74
75    private static final int SORT_BY_NAME = 0;
76    private static final int SORT_BY_GMT_OFFSET = 1;
77
78    /**
79     * This must be false for production. If true, turns on logging, test code,
80     * etc.
81     */
82    static final boolean DEBUG = false;
83    static final String TAG = "CitiesActivity";
84
85    private LayoutInflater mFactory;
86    private ListView mCitiesList;
87    private CityAdapter mAdapter;
88    private HashMap<String, CityObj> mUserSelectedCities;
89    private Calendar mCalendar;
90
91    private SearchView mSearchView;
92    private StringBuffer mQueryTextBuffer = new StringBuffer();
93    private boolean mSearchMode;
94    private int mPosition = -1;
95
96    private SharedPreferences mPrefs;
97    private int mSortType;
98
99    private String mSelectedCitiesHeaderString;
100
101    /***
102     * Adapter for a list of cities with the respected time zone. The Adapter
103     * sorts the list alphabetically and create an indexer.
104     ***/
105    private class CityAdapter extends BaseAdapter implements Filterable, SectionIndexer {
106        private static final int VIEW_TYPE_CITY = 0;
107        private static final int VIEW_TYPE_HEADER = 1;
108
109        private static final String DELETED_ENTRY = "C0";
110
111        private List<CityObj> mDisplayedCitiesList;
112
113        private CityObj[] mCities;
114        private CityObj[] mSelectedCities;
115
116        private final int mLayoutDirection;
117
118        // A map that caches names of cities in local memory.  The names in this map are
119        // preferred over the names of the selected cities stored in SharedPreferences, which could
120        // be in a different language.  This map gets reloaded on a locale change, when the new
121        // language's city strings are read from the xml file.
122        private HashMap<String, String> mCityNameMap = new HashMap<String, String>();
123
124        private String[] mSectionHeaders;
125        private Integer[] mSectionPositions;
126
127        private CityNameComparator mSortByNameComparator = new CityNameComparator();
128        private CityGmtOffsetComparator mSortByTimeComparator = new CityGmtOffsetComparator();
129
130        private final LayoutInflater mInflater;
131        private boolean mIs24HoursMode; // AM/PM or 24 hours mode
132
133        private final String mPattern12;
134        private final String mPattern24;
135
136        private int mSelectedEndPosition = 0;
137
138        private Filter mFilter = new Filter() {
139
140            @Override
141            protected synchronized FilterResults performFiltering(CharSequence constraint) {
142                FilterResults results = new FilterResults();
143                String modifiedQuery = constraint.toString().trim().toUpperCase();
144
145                ArrayList<CityObj> filteredList = new ArrayList<CityObj>();
146                ArrayList<String> sectionHeaders = new ArrayList<String>();
147                ArrayList<Integer> sectionPositions = new ArrayList<Integer>();
148
149                // Update the list first when user using search filter
150                final Collection<CityObj> selectedCities = mUserSelectedCities.values();
151                mSelectedCities = selectedCities.toArray(new CityObj[selectedCities.size()]);
152                // If the search query is empty, add in the selected cities
153                if (TextUtils.isEmpty(modifiedQuery) && mSelectedCities != null) {
154                    if (mSelectedCities.length > 0) {
155                        sectionHeaders.add("+");
156                        sectionPositions.add(0);
157                        filteredList.add(new CityObj(mSelectedCitiesHeaderString,
158                                mSelectedCitiesHeaderString,
159                                null));
160                    }
161                    for (CityObj city : mSelectedCities) {
162                        city.isHeader = false;
163                        filteredList.add(city);
164                    }
165                }
166
167                final HashSet<String> selectedCityIds = new HashSet<>();
168                for (CityObj c : mSelectedCities) {
169                    selectedCityIds.add(c.mCityId);
170                }
171                mSelectedEndPosition = filteredList.size();
172
173                long currentTime = System.currentTimeMillis();
174                String val = null;
175                int offset = -100000; //some value that cannot be a real offset
176                for (CityObj city : mCities) {
177
178                    // If the city is a deleted entry, ignore it.
179                    if (city.mCityId.equals(DELETED_ENTRY)) {
180                        continue;
181                    }
182
183                    // If the search query is empty, add section headers.
184                    if (TextUtils.isEmpty(modifiedQuery)) {
185                        if (!selectedCityIds.contains(city.mCityId)) {
186                            // If the list is sorted by name, and the city begins with a letter
187                            // different than the previous city's letter, insert a section header.
188                            if (mSortType == SORT_BY_NAME
189                                    && !city.mCityName.substring(0, 1).equals(val)) {
190                                val = city.mCityName.substring(0, 1).toUpperCase();
191                                sectionHeaders.add(val);
192                                sectionPositions.add(filteredList.size());
193                                city.isHeader = true;
194                            } else {
195                                city.isHeader = false;
196                            }
197
198                            // If the list is sorted by time, and the gmt offset is different than
199                            // the previous city's gmt offset, insert a section header.
200                            if (mSortType == SORT_BY_GMT_OFFSET) {
201                                TimeZone timezone = TimeZone.getTimeZone(city.mTimeZone);
202                                int newOffset = timezone.getOffset(currentTime);
203                                if (offset != newOffset) {
204                                    offset = newOffset;
205                                    String offsetString = Utils.getGMTHourOffset(timezone, true);
206                                    sectionHeaders.add(offsetString);
207                                    sectionPositions.add(filteredList.size());
208                                    city.isHeader = true;
209                                } else {
210                                    city.isHeader = false;
211                                }
212                            }
213
214                            filteredList.add(city);
215                        }
216                    } else {
217                        // If the city name begins with the non-empty query, add it into the list.
218                        String cityName = city.mCityName.trim().toUpperCase();
219                        if (city.mCityId != null && cityName.startsWith(modifiedQuery)) {
220                            city.isHeader = false;
221                            filteredList.add(city);
222                        }
223                    }
224                }
225
226                mSectionHeaders = sectionHeaders.toArray(new String[sectionHeaders.size()]);
227                mSectionPositions = sectionPositions.toArray(new Integer[sectionPositions.size()]);
228
229                results.values = filteredList;
230                results.count = filteredList.size();
231                return results;
232            }
233
234            @Override
235            protected void publishResults(CharSequence constraint, FilterResults results) {
236                mDisplayedCitiesList = (ArrayList<CityObj>) results.values;
237                if (mPosition >= 0) {
238                    mCitiesList.setSelectionFromTop(mPosition, 0);
239                    mPosition = -1;
240                }
241                notifyDataSetChanged();
242            }
243        };
244
245        public CityAdapter(
246                Context context, LayoutInflater factory) {
247            super();
248            mCalendar = Calendar.getInstance();
249            mCalendar.setTimeInMillis(System.currentTimeMillis());
250            mLayoutDirection = TextUtils.getLayoutDirectionFromLocale(Locale.getDefault());
251            mInflater = factory;
252
253            // Load the cities from xml.
254            mCities = Utils.loadCitiesFromXml(context);
255
256            // Reload the city name map with the recently parsed city names of the currently
257            // selected language for use with selected cities.
258            mCityNameMap.clear();
259            for (CityObj city : mCities) {
260                mCityNameMap.put(city.mCityId, city.mCityName);
261            }
262
263            // Re-organize the selected cities into an array.
264            Collection<CityObj> selectedCities = mUserSelectedCities.values();
265            mSelectedCities = selectedCities.toArray(new CityObj[selectedCities.size()]);
266
267            // Override the selected city names in the shared preferences with the
268            // city names in the updated city name map, which will always reflect the
269            // current language.
270            for (CityObj city : mSelectedCities) {
271                String newCityName = mCityNameMap.get(city.mCityId);
272                if (newCityName != null) {
273                    city.mCityName = newCityName;
274                }
275            }
276
277            mPattern24 = DateFormat.getBestDateTimePattern(Locale.getDefault(), "Hm");
278
279            // There's an RTL layout bug that causes jank when fast-scrolling through
280            // the list in 12-hour mode in an RTL locale. We can work around this by
281            // ensuring the strings are the same length by using "hh" instead of "h".
282            String pattern12 = DateFormat.getBestDateTimePattern(Locale.getDefault(), "hma");
283            if (mLayoutDirection == View.LAYOUT_DIRECTION_RTL) {
284                pattern12 = pattern12.replaceAll("h", "hh");
285            }
286            mPattern12 = pattern12;
287
288            sortCities(mSortType);
289            set24HoursMode(context);
290        }
291
292        public void toggleSort() {
293            if (mSortType == SORT_BY_NAME) {
294                sortCities(SORT_BY_GMT_OFFSET);
295            } else {
296                sortCities(SORT_BY_NAME);
297            }
298        }
299
300        private void sortCities(final int sortType) {
301            mSortType = sortType;
302            Arrays.sort(mCities, sortType == SORT_BY_NAME ? mSortByNameComparator
303                    : mSortByTimeComparator);
304            if (mSelectedCities != null) {
305                Arrays.sort(mSelectedCities, sortType == SORT_BY_NAME ? mSortByNameComparator
306                        : mSortByTimeComparator);
307            }
308            mPrefs.edit().putInt(PREF_SORT, sortType).commit();
309            mFilter.filter(mQueryTextBuffer.toString());
310        }
311
312        @Override
313        public int getCount() {
314            return mDisplayedCitiesList != null ? mDisplayedCitiesList.size() : 0;
315        }
316
317        @Override
318        public Object getItem(int p) {
319            if (mDisplayedCitiesList != null && p >= 0 && p < mDisplayedCitiesList.size()) {
320                return mDisplayedCitiesList.get(p);
321            }
322            return null;
323        }
324
325        @Override
326        public long getItemId(int p) {
327            return p;
328        }
329
330        @Override
331        public boolean isEnabled(int p) {
332            return mDisplayedCitiesList != null && mDisplayedCitiesList.get(p).mCityId != null;
333        }
334
335        @Override
336        public synchronized View getView(int position, View view, ViewGroup parent) {
337            if (mDisplayedCitiesList == null || position < 0
338                    || position >= mDisplayedCitiesList.size()) {
339                return null;
340            }
341            CityObj c = mDisplayedCitiesList.get(position);
342            // Header view: A CityObj with nothing but the "selected cities" label
343            if (c.mCityId == null) {
344                if (view == null) {
345                    view = mInflater.inflate(R.layout.city_list_header, parent, false);
346                }
347            } else { // City view
348                // Make sure to recycle a City view only
349                if (view == null) {
350                    view = mInflater.inflate(R.layout.city_list_item, parent, false);
351                    final CityViewHolder holder = new CityViewHolder();
352                    holder.index = (TextView) view.findViewById(R.id.index);
353                    holder.name = (TextView) view.findViewById(R.id.city_name);
354                    holder.time = (TextView) view.findViewById(R.id.city_time);
355                    holder.selected = (CheckBox) view.findViewById(R.id.city_onoff);
356                    view.setTag(holder);
357                }
358                view.setOnClickListener(CitiesActivity.this);
359                CityViewHolder holder = (CityViewHolder) view.getTag();
360
361                holder.selected.setTag(c);
362                holder.selected.setChecked(mUserSelectedCities.containsKey(c.mCityId));
363                holder.selected.setOnCheckedChangeListener(CitiesActivity.this);
364                holder.name.setText(c.mCityName, TextView.BufferType.SPANNABLE);
365                holder.time.setText(getTimeCharSequence(c.mTimeZone));
366                if (c.isHeader) {
367                    holder.index.setVisibility(View.VISIBLE);
368                    if (mSortType == SORT_BY_NAME) {
369                        holder.index.setText(c.mCityName.substring(0, 1));
370                        holder.index.setTextSize(TypedValue.COMPLEX_UNIT_SP, 24);
371                    } else { // SORT_BY_GMT_OFFSET
372                        holder.index.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14);
373                        holder.index.setText(Utils.getGMTHourOffset(
374                                TimeZone.getTimeZone(c.mTimeZone), true));
375                    }
376                } else {
377                    // If not a header, use the invisible index for left padding
378                    holder.index.setVisibility(View.INVISIBLE);
379                }
380                // skip checkbox and other animations
381                view.jumpDrawablesToCurrentState();
382            }
383            return view;
384        }
385
386        private CharSequence getTimeCharSequence(String timeZone) {
387            mCalendar.setTimeZone(TimeZone.getTimeZone(timeZone));
388            return DateFormat.format(mIs24HoursMode ? mPattern24 : mPattern12, mCalendar);
389        }
390
391        @Override
392        public int getViewTypeCount() {
393            return 2;
394        }
395
396        @Override
397        public int getItemViewType(int position) {
398            return (mDisplayedCitiesList.get(position).mCityId != null)
399                    ? VIEW_TYPE_CITY : VIEW_TYPE_HEADER;
400        }
401
402        private class CityViewHolder {
403            TextView index;
404            TextView name;
405            TextView time;
406            CheckBox selected;
407        }
408
409        public void set24HoursMode(Context c) {
410            mIs24HoursMode = DateFormat.is24HourFormat(c);
411            notifyDataSetChanged();
412        }
413
414        @Override
415        public int getPositionForSection(int section) {
416            return !isEmpty(mSectionPositions) ? mSectionPositions[section] : 0;
417        }
418
419
420        @Override
421        public int getSectionForPosition(int p) {
422            final Integer[] positions = mSectionPositions;
423            if (!isEmpty(positions)) {
424                for (int i = 0; i < positions.length - 1; i++) {
425                    if (p >= positions[i]
426                            && p < positions[i + 1]) {
427                        return i;
428                    }
429                }
430                if (p >= positions[positions.length - 1]) {
431                    return positions.length - 1;
432                }
433            }
434            return 0;
435        }
436
437        @Override
438        public Object[] getSections() {
439            return mSectionHeaders;
440        }
441
442        @Override
443        public Filter getFilter() {
444            return mFilter;
445        }
446
447        private boolean isEmpty(Object[] array) {
448            return array == null || array.length == 0;
449        }
450    }
451
452    @Override
453    protected void onCreate(Bundle savedInstanceState) {
454        super.onCreate(savedInstanceState);
455        mFactory = LayoutInflater.from(this);
456        mPrefs = PreferenceManager.getDefaultSharedPreferences(this);
457        mSortType = mPrefs.getInt(PREF_SORT, SORT_BY_NAME);
458        mSelectedCitiesHeaderString = getString(R.string.selected_cities_label);
459        if (savedInstanceState != null) {
460            mQueryTextBuffer.append(savedInstanceState.getString(KEY_SEARCH_QUERY));
461            mSearchMode = savedInstanceState.getBoolean(KEY_SEARCH_MODE);
462            mPosition = savedInstanceState.getInt(KEY_LIST_POSITION);
463        }
464        updateLayout();
465    }
466
467    @Override
468    public void onSaveInstanceState(Bundle bundle) {
469        super.onSaveInstanceState(bundle);
470        bundle.putString(KEY_SEARCH_QUERY, mQueryTextBuffer.toString());
471        bundle.putBoolean(KEY_SEARCH_MODE, mSearchMode);
472        bundle.putInt(KEY_LIST_POSITION, mCitiesList.getFirstVisiblePosition());
473    }
474
475    private void updateLayout() {
476        setContentView(R.layout.cities_activity);
477        mCitiesList = (ListView) findViewById(R.id.cities_list);
478        setFastScroll(TextUtils.isEmpty(mQueryTextBuffer.toString().trim()));
479        mCitiesList.setScrollBarStyle(View.SCROLLBARS_INSIDE_INSET);
480        mUserSelectedCities = Cities.readCitiesFromSharedPrefs(
481                PreferenceManager.getDefaultSharedPreferences(this));
482        mAdapter = new CityAdapter(this, mFactory);
483        mCitiesList.setAdapter(mAdapter);
484        ActionBar actionBar = getActionBar();
485        if (actionBar != null) {
486            actionBar.setDisplayOptions(ActionBar.DISPLAY_HOME_AS_UP, ActionBar.DISPLAY_HOME_AS_UP);
487        }
488    }
489
490    private void setFastScroll(boolean enabled) {
491        if (mCitiesList != null) {
492            mCitiesList.setFastScrollAlwaysVisible(enabled);
493            mCitiesList.setFastScrollEnabled(enabled);
494        }
495    }
496
497    @Override
498    public void onResume() {
499        super.onResume();
500        if (mAdapter != null) {
501            mAdapter.set24HoursMode(this);
502        }
503
504        getWindow().getDecorView().setBackgroundColor(Utils.getCurrentHourColor());
505    }
506
507    @Override
508    public void onPause() {
509        super.onPause();
510        Cities.saveCitiesToSharedPrefs(PreferenceManager.getDefaultSharedPreferences(this),
511                mUserSelectedCities);
512        Intent i = new Intent(Cities.WORLDCLOCK_UPDATE_INTENT);
513        sendBroadcast(i);
514    }
515
516    @Override
517    public boolean onOptionsItemSelected(MenuItem item) {
518        switch (item.getItemId()) {
519            case android.R.id.home:
520                finish();
521                return true;
522            case R.id.menu_item_settings:
523                startActivity(new Intent(this, SettingsActivity.class));
524                return true;
525            case R.id.menu_item_help:
526                Intent i = item.getIntent();
527                if (i != null) {
528                    try {
529                        startActivity(i);
530                    } catch (ActivityNotFoundException e) {
531                        // No activity found to match the intent - ignore
532                    }
533                }
534                return true;
535            case R.id.menu_item_sort:
536                if (mAdapter != null) {
537                    mAdapter.toggleSort();
538                    setFastScroll(TextUtils.isEmpty(mQueryTextBuffer.toString().trim()));
539                }
540                return true;
541            default:
542                break;
543        }
544        return super.onOptionsItemSelected(item);
545    }
546
547    @Override
548    public boolean onCreateOptionsMenu(Menu menu) {
549        getMenuInflater().inflate(R.menu.cities_menu, menu);
550        MenuItem help = menu.findItem(R.id.menu_item_help);
551        if (help != null) {
552            Utils.prepareHelpMenuItem(this, help);
553        }
554
555        MenuItem searchMenu = menu.findItem(R.id.menu_item_search);
556        mSearchView = (SearchView) searchMenu.getActionView();
557        mSearchView.setImeOptions(EditorInfo.IME_FLAG_NO_EXTRACT_UI);
558        mSearchView.setOnSearchClickListener(new OnClickListener() {
559
560            @Override
561            public void onClick(View arg0) {
562                mSearchMode = true;
563            }
564        });
565        mSearchView.setOnCloseListener(new SearchView.OnCloseListener() {
566
567            @Override
568            public boolean onClose() {
569                mSearchMode = false;
570                return false;
571            }
572        });
573        if (mSearchView != null) {
574            mSearchView.setOnQueryTextListener(this);
575            mSearchView.setQuery(mQueryTextBuffer.toString(), false);
576            if (mSearchMode) {
577                mSearchView.requestFocus();
578                mSearchView.setIconified(false);
579            }
580        }
581        return super.onCreateOptionsMenu(menu);
582    }
583
584    @Override
585    public boolean onPrepareOptionsMenu(Menu menu) {
586        MenuItem sortMenuItem = menu.findItem(R.id.menu_item_sort);
587        if (mSortType == SORT_BY_NAME) {
588            sortMenuItem.setTitle(getString(R.string.menu_item_sort_by_gmt_offset));
589        } else {
590            sortMenuItem.setTitle(getString(R.string.menu_item_sort_by_name));
591        }
592        return super.onPrepareOptionsMenu(menu);
593    }
594
595    @Override
596    public void onCheckedChanged(CompoundButton b, boolean checked) {
597        CityObj c = (CityObj) b.getTag();
598        if (checked) {
599            mUserSelectedCities.put(c.mCityId, c);
600        } else {
601            mUserSelectedCities.remove(c.mCityId);
602        }
603    }
604
605    @Override
606    public void onClick(View v) {
607        CompoundButton b = (CompoundButton) v.findViewById(R.id.city_onoff);
608        boolean checked = b.isChecked();
609        onCheckedChanged(b, checked);
610        b.setChecked(!checked);
611    }
612
613    @Override
614    public boolean onQueryTextChange(String queryText) {
615        mQueryTextBuffer.setLength(0);
616        mQueryTextBuffer.append(queryText);
617        mCitiesList.setFastScrollEnabled(TextUtils.isEmpty(mQueryTextBuffer.toString().trim()));
618        mAdapter.getFilter().filter(queryText);
619        return true;
620    }
621
622    @Override
623    public boolean onQueryTextSubmit(String arg0) {
624        return false;
625    }
626}
627