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.view.LayoutInflater;
30import android.view.Menu;
31import android.view.MenuItem;
32import android.view.View;
33import android.view.View.OnClickListener;
34import android.view.ViewGroup;
35import android.view.inputmethod.EditorInfo;
36import android.widget.BaseAdapter;
37import android.widget.CheckBox;
38import android.widget.CompoundButton;
39import android.widget.CompoundButton.OnCheckedChangeListener;
40import android.widget.Filter;
41import android.widget.Filterable;
42import android.widget.ImageView;
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.DeskClock;
50import com.android.deskclock.R;
51import com.android.deskclock.SettingsActivity;
52import com.android.deskclock.Utils;
53
54import java.util.ArrayList;
55import java.util.Arrays;
56import java.util.Calendar;
57import java.util.Collection;
58import java.util.HashMap;
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                // If the search query is empty, add in the selected cities
150                if (TextUtils.isEmpty(modifiedQuery) && mSelectedCities != null) {
151                    if (mSelectedCities.length > 0) {
152                        sectionHeaders.add("+");
153                        sectionPositions.add(0);
154                        filteredList.add(new CityObj(mSelectedCitiesHeaderString,
155                                mSelectedCitiesHeaderString,
156                                null));
157                    }
158                    for (CityObj city : mSelectedCities) {
159                        filteredList.add(city);
160                    }
161                }
162
163                mSelectedEndPosition = filteredList.size();
164
165                long currentTime = System.currentTimeMillis();
166                String val = null;
167                int offset = -100000; //some value that cannot be a real offset
168                for (CityObj city : mCities) {
169
170                    // If the city is a deleted entry, ignore it.
171                    if (city.mCityId.equals(DELETED_ENTRY)) {
172                        continue;
173                    }
174
175                    // If the search query is empty, add section headers.
176                    if (TextUtils.isEmpty(modifiedQuery)) {
177
178
179                        // If the list is sorted by name, and the city begins with a letter
180                        // different than the previous city's letter, insert a section header.
181                        if (mSortType == SORT_BY_NAME
182                                && !city.mCityName.substring(0, 1).equals(val)) {
183                                val = city.mCityName.substring(0, 1).toUpperCase();
184                                sectionHeaders.add(val);
185                                sectionPositions.add(filteredList.size());
186                                filteredList.add(new CityObj(val, null, null));
187                        }
188
189                        // If the list is sorted by time, and the gmt offset is different than
190                        // the previous city's gmt offset, insert a section header.
191                        if (mSortType == SORT_BY_GMT_OFFSET) {
192                            TimeZone timezone = TimeZone.getTimeZone(city.mTimeZone);
193                            int newOffset = timezone.getOffset(currentTime);
194                            if (offset != newOffset) {
195                                offset = newOffset;
196                                String offsetString = Utils.getGMTHourOffset(timezone, true);
197                                sectionHeaders.add(offsetString);
198                                sectionPositions.add(filteredList.size());
199                                filteredList.add(new CityObj(null, offsetString, null));
200                            }
201                        }
202                    }
203
204                    // If the city name begins with the query, add the city into the list.
205                    // If the query is empty, the city will automatically be added to the list.
206                    String cityName = city.mCityName.trim().toUpperCase();
207                    if (city.mCityId != null && cityName.startsWith(modifiedQuery)) {
208                        filteredList.add(city);
209                    }
210                }
211
212                mSectionHeaders = sectionHeaders.toArray(new String[sectionHeaders.size()]);
213                mSectionPositions = sectionPositions.toArray(new Integer[sectionPositions.size()]);
214
215                results.values = filteredList;
216                results.count = filteredList.size();
217                return results;
218            }
219
220            @Override
221            protected void publishResults(CharSequence constraint, FilterResults results) {
222                mDisplayedCitiesList = (ArrayList<CityObj>) results.values;
223                if (mPosition >= 0) {
224                    mCitiesList.setSelectionFromTop(mPosition, 0);
225                    mPosition = -1;
226                }
227                notifyDataSetChanged();
228            }
229        };
230
231        public CityAdapter(
232                Context context, LayoutInflater factory) {
233            super();
234            mCalendar = Calendar.getInstance();
235            mCalendar.setTimeInMillis(System.currentTimeMillis());
236            mLayoutDirection = TextUtils.getLayoutDirectionFromLocale(Locale.getDefault());
237            mInflater = factory;
238
239            // Load the cities from xml.
240            mCities = Utils.loadCitiesFromXml(context);
241
242            // Reload the city name map with the recently parsed city names of the currently
243            // selected language for use with selected cities.
244            mCityNameMap.clear();
245            for (CityObj city : mCities) {
246                mCityNameMap.put(city.mCityId, city.mCityName);
247            }
248
249            // Re-organize the selected cities into an array.
250            Collection<CityObj> selectedCities = mUserSelectedCities.values();
251            mSelectedCities = selectedCities.toArray(new CityObj[selectedCities.size()]);
252
253            // Override the selected city names in the shared preferences with the
254            // city names in the updated city name map, which will always reflect the
255            // current language.
256            for (CityObj city : mSelectedCities) {
257                String newCityName = mCityNameMap.get(city.mCityId);
258                if (newCityName != null) {
259                    city.mCityName = newCityName;
260                }
261            }
262
263            mPattern24 = DateFormat.getBestDateTimePattern(Locale.getDefault(), "Hm");
264
265            // There's an RTL layout bug that causes jank when fast-scrolling through
266            // the list in 12-hour mode in an RTL locale. We can work around this by
267            // ensuring the strings are the same length by using "hh" instead of "h".
268            String pattern12 = DateFormat.getBestDateTimePattern(Locale.getDefault(), "hma");
269            if (mLayoutDirection == View.LAYOUT_DIRECTION_RTL) {
270                pattern12 = pattern12.replaceAll("h", "hh");
271            }
272            mPattern12 = pattern12;
273
274            sortCities(mSortType);
275            set24HoursMode(context);
276        }
277
278        public void refreshSelectedCities() {
279            Collection<CityObj> selectedCities = mUserSelectedCities.values();
280            mSelectedCities = selectedCities.toArray(new CityObj[selectedCities.size()]);
281            sortCities(mSortType);
282        }
283
284        public void toggleSort() {
285            if (mSortType == SORT_BY_NAME) {
286                sortCities(SORT_BY_GMT_OFFSET);
287            } else {
288                sortCities(SORT_BY_NAME);
289            }
290        }
291
292        private void sortCities(final int sortType) {
293            mSortType = sortType;
294            Arrays.sort(mCities, sortType == SORT_BY_NAME ? mSortByNameComparator
295                    : mSortByTimeComparator);
296            if (mSelectedCities != null) {
297                Arrays.sort(mSelectedCities, sortType == SORT_BY_NAME ? mSortByNameComparator
298                        : mSortByTimeComparator);
299            }
300            mPrefs.edit().putInt(PREF_SORT, sortType).commit();
301            mFilter.filter(mQueryTextBuffer.toString());
302        }
303
304        @Override
305        public int getCount() {
306            return mDisplayedCitiesList != null ? mDisplayedCitiesList.size() : 0;
307        }
308
309        @Override
310        public Object getItem(int p) {
311            if (mDisplayedCitiesList != null && p >= 0 && p < mDisplayedCitiesList.size()) {
312                return mDisplayedCitiesList.get(p);
313            }
314            return null;
315        }
316
317        @Override
318        public long getItemId(int p) {
319            return p;
320        }
321
322        @Override
323        public boolean isEnabled(int p) {
324            return mDisplayedCitiesList != null && mDisplayedCitiesList.get(p).mCityId != null;
325        }
326
327        @Override
328        public synchronized View getView(int position, View view, ViewGroup parent) {
329            if (mDisplayedCitiesList == null || position < 0
330                    || position >= mDisplayedCitiesList.size()) {
331                return null;
332            }
333            CityObj c = mDisplayedCitiesList.get(position);
334            // Header view: A CityObj with nothing but the first letter as the name
335            if (c.mCityId == null) {
336                if (view == null) {
337                    view = mInflater.inflate(R.layout.city_list_header, parent, false);
338                    view.setTag(view.findViewById(R.id.header));
339                }
340                ((TextView) view.getTag()).setText(
341                        mSortType == SORT_BY_NAME ? c.mCityName : c.mTimeZone);
342            } else { // City view
343                // Make sure to recycle a City view only
344                if (view == null) {
345                    view = mInflater.inflate(R.layout.city_list_item, parent, false);
346                    final CityViewHolder holder = new CityViewHolder();
347                    holder.name = (TextView) view.findViewById(R.id.city_name);
348                    holder.time = (TextView) view.findViewById(R.id.city_time);
349                    holder.selected = (CheckBox) view.findViewById(R.id.city_onoff);
350                    holder.selectedPin = (ImageView) view.findViewById(R.id.city_selected_icon);
351                    holder.remove = (ImageView) view.findViewById(R.id.city_remove);
352                    holder.remove.setOnClickListener(new OnClickListener() {
353
354                        @Override
355                        public void onClick(View view) {
356                            CompoundButton b = holder.selected;
357                            onCheckedChanged(b, false);
358                            b.setChecked(false);
359                            mAdapter.refreshSelectedCities();
360                        }
361                    });
362                    view.setTag(holder);
363                }
364                view.setOnClickListener(CitiesActivity.this);
365                CityViewHolder holder = (CityViewHolder) view.getTag();
366
367                if (position < mSelectedEndPosition) {
368                    holder.selected.setVisibility(View.GONE);
369                    holder.time.setVisibility(View.GONE);
370                    holder.remove.setVisibility(View.VISIBLE);
371                    holder.selectedPin.setVisibility(View.VISIBLE);
372                    view.setEnabled(false);
373                } else {
374                    holder.selected.setVisibility(View.VISIBLE);
375                    holder.time.setVisibility(View.VISIBLE);
376                    holder.remove.setVisibility(View.GONE);
377                    holder.selectedPin.setVisibility(View.GONE);
378                    view.setEnabled(true);
379                }
380                holder.selected.setTag(c);
381                holder.selected.setChecked(mUserSelectedCities.containsKey(c.mCityId));
382                holder.selected.setOnCheckedChangeListener(CitiesActivity.this);
383                holder.name.setText(c.mCityName, TextView.BufferType.SPANNABLE);
384                holder.time.setText(getTimeCharSequence(c.mTimeZone));
385            }
386            return view;
387        }
388
389        private CharSequence getTimeCharSequence(String timeZone) {
390            mCalendar.setTimeZone(TimeZone.getTimeZone(timeZone));
391            return DateFormat.format(mIs24HoursMode ? mPattern24 : mPattern12, mCalendar);
392        }
393
394        @Override
395        public int getViewTypeCount() {
396            return 2;
397        }
398
399        @Override
400        public int getItemViewType(int position) {
401            return (mDisplayedCitiesList.get(position).mCityId != null)
402                    ? VIEW_TYPE_CITY : VIEW_TYPE_HEADER;
403        }
404
405        private class CityViewHolder {
406            TextView name;
407            TextView time;
408            CheckBox selected;
409            ImageView selectedPin;
410            ImageView remove;
411        }
412
413        public void set24HoursMode(Context c) {
414            mIs24HoursMode = DateFormat.is24HourFormat(c);
415            notifyDataSetChanged();
416        }
417
418        @Override
419        public int getPositionForSection(int section) {
420            return !isEmpty(mSectionPositions) ? mSectionPositions[section] : 0;
421        }
422
423
424        @Override
425        public int getSectionForPosition(int p) {
426            final Integer[] positions = mSectionPositions;
427            if (!isEmpty(positions)) {
428                for (int i = 0; i < positions.length - 1; i++) {
429                    if (p >= positions[i]
430                            && p < positions[i + 1]) {
431                        return i;
432                    }
433                }
434                if (p >= positions[positions.length - 1]) {
435                    return positions.length - 1;
436                }
437            }
438            return 0;
439        }
440
441        @Override
442        public Object[] getSections() {
443            return mSectionHeaders;
444        }
445
446        @Override
447        public Filter getFilter() {
448            return mFilter;
449        }
450
451        private boolean isEmpty(Object[] array) {
452            return array == null || array.length == 0;
453        }
454    }
455
456    @Override
457    protected void onCreate(Bundle savedInstanceState) {
458        super.onCreate(savedInstanceState);
459        mFactory = LayoutInflater.from(this);
460        mPrefs = PreferenceManager.getDefaultSharedPreferences(this);
461        mSortType = mPrefs.getInt(PREF_SORT, SORT_BY_NAME);
462        mSelectedCitiesHeaderString = getString(R.string.selected_cities_label);
463        if (savedInstanceState != null) {
464            mQueryTextBuffer.append(savedInstanceState.getString(KEY_SEARCH_QUERY));
465            mSearchMode = savedInstanceState.getBoolean(KEY_SEARCH_MODE);
466            mPosition = savedInstanceState.getInt(KEY_LIST_POSITION);
467        }
468        updateLayout();
469    }
470
471    @Override
472    public void onSaveInstanceState(Bundle bundle) {
473        super.onSaveInstanceState(bundle);
474        bundle.putString(KEY_SEARCH_QUERY, mQueryTextBuffer.toString());
475        bundle.putBoolean(KEY_SEARCH_MODE, mSearchMode);
476        bundle.putInt(KEY_LIST_POSITION, mCitiesList.getFirstVisiblePosition());
477    }
478
479    private void updateLayout() {
480        setContentView(R.layout.cities_activity);
481        mCitiesList = (ListView) findViewById(R.id.cities_list);
482        setFastScroll(TextUtils.isEmpty(mQueryTextBuffer.toString().trim()));
483        mCitiesList.setScrollBarStyle(View.SCROLLBARS_INSIDE_INSET);
484        mCitiesList.setFastScrollEnabled(true);
485        mUserSelectedCities = Cities.readCitiesFromSharedPrefs(
486                PreferenceManager.getDefaultSharedPreferences(this));
487        mAdapter = new CityAdapter(this, mFactory);
488        mCitiesList.setAdapter(mAdapter);
489        ActionBar actionBar = getActionBar();
490        if (actionBar != null) {
491            actionBar.setDisplayOptions(ActionBar.DISPLAY_HOME_AS_UP, ActionBar.DISPLAY_HOME_AS_UP);
492        }
493    }
494
495    private void setFastScroll(boolean enabled) {
496        if (mCitiesList != null) {
497            mCitiesList.setFastScrollAlwaysVisible(enabled);
498            mCitiesList.setFastScrollEnabled(enabled);
499        }
500    }
501
502    @Override
503    public void onResume() {
504        super.onResume();
505        if (mAdapter != null) {
506            mAdapter.set24HoursMode(this);
507        }
508    }
509
510    @Override
511    public void onPause() {
512        super.onPause();
513        Cities.saveCitiesToSharedPrefs(PreferenceManager.getDefaultSharedPreferences(this),
514                mUserSelectedCities);
515        Intent i = new Intent(Cities.WORLDCLOCK_UPDATE_INTENT);
516        sendBroadcast(i);
517    }
518
519    @Override
520    public boolean onOptionsItemSelected(MenuItem item) {
521        switch (item.getItemId()) {
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            case android.R.id.home:
542                Intent intent = new Intent(this, DeskClock.class);
543                intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
544                startActivity(intent);
545                return true;
546            default:
547                break;
548        }
549        return super.onOptionsItemSelected(item);
550    }
551
552    @Override
553    public boolean onCreateOptionsMenu(Menu menu) {
554        getMenuInflater().inflate(R.menu.cities_menu, menu);
555        MenuItem help = menu.findItem(R.id.menu_item_help);
556        if (help != null) {
557            Utils.prepareHelpMenuItem(this, help);
558        }
559
560        MenuItem searchMenu = menu.findItem(R.id.menu_item_search);
561        mSearchView = (SearchView) searchMenu.getActionView();
562        mSearchView.setImeOptions(EditorInfo.IME_FLAG_NO_EXTRACT_UI);
563        mSearchView.setOnSearchClickListener(new OnClickListener() {
564
565            @Override
566            public void onClick(View arg0) {
567                mSearchMode = true;
568            }
569        });
570        mSearchView.setOnCloseListener(new SearchView.OnCloseListener() {
571
572            @Override
573            public boolean onClose() {
574                mSearchMode = false;
575                return false;
576            }
577        });
578        if (mSearchView != null) {
579            mSearchView.setOnQueryTextListener(this);
580            mSearchView.setQuery(mQueryTextBuffer.toString(), false);
581            if (mSearchMode) {
582                mSearchView.requestFocus();
583                mSearchView.setIconified(false);
584            }
585        }
586        return super.onCreateOptionsMenu(menu);
587    }
588
589    @Override
590    public boolean onPrepareOptionsMenu(Menu menu) {
591        MenuItem sortMenuItem = menu.findItem(R.id.menu_item_sort);
592        if (mSortType == SORT_BY_NAME) {
593            sortMenuItem.setTitle(getString(R.string.menu_item_sort_by_gmt_offset));
594        } else {
595            sortMenuItem.setTitle(getString(R.string.menu_item_sort_by_name));
596        }
597        return super.onPrepareOptionsMenu(menu);
598    }
599
600    @Override
601    public void onCheckedChanged(CompoundButton b, boolean checked) {
602        CityObj c = (CityObj) b.getTag();
603        if (checked) {
604            mUserSelectedCities.put(c.mCityId, c);
605        } else {
606            mUserSelectedCities.remove(c.mCityId);
607        }
608    }
609
610    @Override
611    public void onClick(View v) {
612        CompoundButton b = (CompoundButton) v.findViewById(R.id.city_onoff);
613        boolean checked = b.isChecked();
614        onCheckedChanged(b, checked);
615        b.setChecked(!checked);
616        mAdapter.refreshSelectedCities();
617    }
618
619    @Override
620    public boolean onQueryTextChange(String queryText) {
621        mQueryTextBuffer.setLength(0);
622        mQueryTextBuffer.append(queryText);
623        mCitiesList.setFastScrollEnabled(TextUtils.isEmpty(mQueryTextBuffer.toString().trim()));
624        mAdapter.getFilter().filter(queryText);
625        return true;
626    }
627
628    @Override
629    public boolean onQueryTextSubmit(String arg0) {
630        return false;
631    }
632}
633