1/*
2 * Copyright (C) 2013 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.timezonepicker;
18
19import android.content.Context;
20import android.text.TextUtils;
21import android.util.Log;
22import android.view.LayoutInflater;
23import android.view.View;
24import android.view.View.OnClickListener;
25import android.view.ViewGroup;
26import android.widget.BaseAdapter;
27import android.widget.Filter;
28import android.widget.Filterable;
29import android.widget.TextView;
30
31import java.util.ArrayList;
32import java.util.Collections;
33
34public class TimeZoneFilterTypeAdapter extends BaseAdapter implements Filterable, OnClickListener {
35    public static final String TAG = "TimeZoneFilterTypeAdapter";
36
37    private static final boolean DEBUG = false;
38
39    public static final int FILTER_TYPE_EMPTY = -1;
40    public static final int FILTER_TYPE_NONE = 0;
41    public static final int FILTER_TYPE_COUNTRY = 1;
42    public static final int FILTER_TYPE_STATE = 2;
43    public static final int FILTER_TYPE_GMT = 3;
44
45    public interface OnSetFilterListener {
46        void onSetFilter(int filterType, String str, int time);
47    }
48
49    static class ViewHolder {
50        int filterType;
51        String str;
52        int time;
53        TextView strTextView;
54
55        static void setupViewHolder(View v) {
56            ViewHolder vh = new ViewHolder();
57            vh.strTextView = (TextView) v.findViewById(R.id.value);
58            v.setTag(vh);
59        }
60    }
61
62    class FilterTypeResult {
63        int type;
64        String constraint;
65        public int time;
66
67        public FilterTypeResult(int type, String constraint, int time) {
68            this.type = type;
69            this.constraint = constraint;
70            this.time = time;
71        }
72
73        @Override
74        public String toString() {
75            return constraint;
76        }
77    }
78
79    private ArrayList<FilterTypeResult> mLiveResults = new ArrayList<FilterTypeResult>();
80    private int mLiveResultsCount = 0;
81
82    private ArrayFilter mFilter;
83
84    private LayoutInflater mInflater;
85
86    private TimeZoneData mTimeZoneData;
87    private OnSetFilterListener mListener;
88
89    public TimeZoneFilterTypeAdapter(Context context, TimeZoneData tzd, OnSetFilterListener l) {
90        mTimeZoneData = tzd;
91        mListener = l;
92        mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
93    }
94
95    @Override
96    public int getCount() {
97        return mLiveResultsCount;
98    }
99
100    @Override
101    public FilterTypeResult getItem(int position) {
102        return mLiveResults.get(position);
103    }
104
105    @Override
106    public long getItemId(int position) {
107        return position;
108    }
109
110    @Override
111    public View getView(int position, View convertView, ViewGroup parent) {
112        View v;
113
114        if (convertView != null) {
115            v = convertView;
116        } else {
117            v = mInflater.inflate(R.layout.time_zone_filter_item, null);
118            ViewHolder.setupViewHolder(v);
119        }
120
121        ViewHolder vh = (ViewHolder) v.getTag();
122
123        if (position >= mLiveResults.size()) {
124            Log.e(TAG, "getView: " + position + " of " + mLiveResults.size());
125        }
126
127        FilterTypeResult filter = mLiveResults.get(position);
128
129        vh.filterType = filter.type;
130        vh.str = filter.constraint;
131        vh.time = filter.time;
132        vh.strTextView.setText(filter.constraint);
133        return v;
134    }
135
136    OnClickListener mDummyListener = new OnClickListener() {
137
138        @Override
139        public void onClick(View v) {
140        }
141    };
142
143    // Implements OnClickListener
144
145    // This onClickListener is actually called from the AutoCompleteTextView's
146    // onItemClickListener. Trying to update the text in AutoCompleteTextView
147    // is causing an infinite loop.
148    @Override
149    public void onClick(View v) {
150        if (mListener != null && v != null) {
151            ViewHolder vh = (ViewHolder) v.getTag();
152            mListener.onSetFilter(vh.filterType, vh.str, vh.time);
153        }
154        notifyDataSetInvalidated();
155    }
156
157    // Implements Filterable
158    @Override
159    public Filter getFilter() {
160        if (mFilter == null) {
161            mFilter = new ArrayFilter();
162        }
163        return mFilter;
164    }
165
166    private class ArrayFilter extends Filter {
167        @Override
168        protected FilterResults performFiltering(CharSequence prefix) {
169            if (DEBUG) {
170                Log.d(TAG, "performFiltering >>>> [" + prefix + "]");
171            }
172
173            FilterResults results = new FilterResults();
174            String prefixString = null;
175            if (prefix != null) {
176                prefixString = prefix.toString().trim().toLowerCase();
177            }
178
179            if (TextUtils.isEmpty(prefixString)) {
180                results.values = null;
181                results.count = 0;
182                return results;
183            }
184
185            // TODO Perf - we can loop through the filtered list if the new
186            // search string starts with the old search string
187            ArrayList<FilterTypeResult> filtered = new ArrayList<FilterTypeResult>();
188
189            // ////////////////////////////////////////
190            // Search by local time and GMT offset
191            // ////////////////////////////////////////
192            boolean gmtOnly = false;
193            int startParsePosition = 0;
194            if (prefixString.charAt(0) == '+' || prefixString.charAt(0) == '-') {
195                gmtOnly = true;
196            }
197
198            if (prefixString.startsWith("gmt")) {
199                startParsePosition = 3;
200                gmtOnly = true;
201            }
202
203            int num = parseNum(prefixString, startParsePosition);
204            if (num != Integer.MIN_VALUE) {
205                boolean positiveOnly = prefixString.length() > startParsePosition
206                        && prefixString.charAt(startParsePosition) == '+';
207                handleSearchByGmt(filtered, num, positiveOnly);
208            }
209
210            // ////////////////////////////////////////
211            // Search by country
212            // ////////////////////////////////////////
213            ArrayList<String> countries = new ArrayList<String>();
214            for (String country : mTimeZoneData.mTimeZonesByCountry.keySet()) {
215                // TODO Perf - cache toLowerCase()?
216                if (!TextUtils.isEmpty(country)) {
217                    final String lowerCaseCountry = country.toLowerCase();
218                    boolean isMatch = false;
219                    if (lowerCaseCountry.startsWith(prefixString)
220                            || (lowerCaseCountry.charAt(0) == prefixString.charAt(0) &&
221                            isStartingInitialsFor(prefixString, lowerCaseCountry))) {
222                        isMatch = true;
223                    } else if (lowerCaseCountry.contains(" ")){
224                        // We should also search other words in the country name, so that
225                        // searches like "Korea" yield "South Korea".
226                        for (String word : lowerCaseCountry.split(" ")) {
227                            if (word.startsWith(prefixString)) {
228                                isMatch = true;
229                                break;
230                            }
231                        }
232                    }
233                    if (isMatch) {
234                        countries.add(country);
235                    }
236                }
237            }
238            if (countries.size() > 0) {
239                // Sort countries alphabetically.
240                Collections.sort(countries);
241                for (String country : countries) {
242                    filtered.add(new FilterTypeResult(FILTER_TYPE_COUNTRY, country, 0));
243                }
244            }
245
246            // ////////////////////////////////////////
247            // TODO Search by state
248            // ////////////////////////////////////////
249            if (DEBUG) {
250                Log.d(TAG, "performFiltering <<<< " + filtered.size() + "[" + prefix + "]");
251            }
252
253            results.values = filtered;
254            results.count = filtered.size();
255            return results;
256        }
257
258        /**
259         * Returns true if the prefixString is an initial for string. Note that
260         * this method will return true even if prefixString does not cover all
261         * the words. Words are separated by non-letters which includes spaces
262         * and symbols).
263         *
264         * For example:
265         * isStartingInitialsFor("UA", "United Arab Emirates") would return true
266         * isStartingInitialsFor("US", "U.S. Virgin Island") would return true
267         *
268         * @param prefixString
269         * @param string
270         * @return
271         */
272        private boolean isStartingInitialsFor(String prefixString, String string) {
273            final int initialLen = prefixString.length();
274            final int strLen = string.length();
275
276            int initialIdx = 0;
277            boolean wasWordBreak = true;
278            for (int i = 0; i < strLen; i++) {
279                if (!Character.isLetter(string.charAt(i))) {
280                    wasWordBreak = true;
281                    continue;
282                }
283
284                if (wasWordBreak) {
285                    if (prefixString.charAt(initialIdx++) != string.charAt(i)) {
286                        return false;
287                    }
288                    if (initialIdx == initialLen) {
289                        return true;
290                    }
291                    wasWordBreak = false;
292                }
293            }
294
295            // Special case for "USA". Note that both strings have been turned to lowercase already.
296            if (prefixString.equals("usa") && string.equals("united states")) {
297                return true;
298            }
299            return false;
300        }
301
302        private void handleSearchByGmt(ArrayList<FilterTypeResult> filtered, int num,
303                boolean positiveOnly) {
304
305            FilterTypeResult r;
306            if (num >= 0) {
307                if (num == 1) {
308                    for (int i = 19; i >= 10; i--) {
309                        if (mTimeZoneData.hasTimeZonesInHrOffset(i)) {
310                            r = new FilterTypeResult(FILTER_TYPE_GMT, "GMT+" + i, i);
311                            filtered.add(r);
312                        }
313                    }
314                }
315
316                if (mTimeZoneData.hasTimeZonesInHrOffset(num)) {
317                    r = new FilterTypeResult(FILTER_TYPE_GMT, "GMT+" + num, num);
318                    filtered.add(r);
319                }
320                num *= -1;
321            }
322
323            if (!positiveOnly && num != 0) {
324                if (mTimeZoneData.hasTimeZonesInHrOffset(num)) {
325                    r = new FilterTypeResult(FILTER_TYPE_GMT, "GMT" + num, num);
326                    filtered.add(r);
327                }
328
329                if (num == -1) {
330                    for (int i = -10; i >= -19; i--) {
331                        if (mTimeZoneData.hasTimeZonesInHrOffset(i)) {
332                            r = new FilterTypeResult(FILTER_TYPE_GMT, "GMT" + i, i);
333                            filtered.add(r);
334                        }
335                    }
336                }
337            }
338        }
339
340        /**
341         * Acceptable strings are in the following format: [+-]?[0-9]?[0-9]
342         *
343         * @param str
344         * @param startIndex
345         * @return Integer.MIN_VALUE as invalid
346         */
347        public int parseNum(String str, int startIndex) {
348            int idx = startIndex;
349            int num = Integer.MIN_VALUE;
350            int negativeMultiplier = 1;
351
352            // First char - check for + and -
353            char ch = str.charAt(idx++);
354            switch (ch) {
355                case '-':
356                    negativeMultiplier = -1;
357                    // fall through
358                case '+':
359                    if (idx >= str.length()) {
360                        // No more digits
361                        return Integer.MIN_VALUE;
362                    }
363
364                    ch = str.charAt(idx++);
365                    break;
366            }
367
368            if (!Character.isDigit(ch)) {
369                // No digit
370                return Integer.MIN_VALUE;
371            }
372
373            // Got first digit
374            num = Character.digit(ch, 10);
375
376            // Check next char
377            if (idx < str.length()) {
378                ch = str.charAt(idx++);
379                if (Character.isDigit(ch)) {
380                    // Got second digit
381                    num = 10 * num + Character.digit(ch, 10);
382                } else {
383                    return Integer.MIN_VALUE;
384                }
385            }
386
387            if (idx != str.length()) {
388                // Invalid
389                return Integer.MIN_VALUE;
390            }
391
392            if (DEBUG) {
393                Log.d(TAG, "Parsing " + str + " -> " + negativeMultiplier * num);
394            }
395            return negativeMultiplier * num;
396        }
397
398        @SuppressWarnings("unchecked")
399        @Override
400        protected void publishResults(CharSequence constraint, FilterResults
401                results) {
402            if (results.values == null || results.count == 0) {
403                if (mListener != null) {
404                    int filterType;
405                    if (TextUtils.isEmpty(constraint)) {
406                        filterType = FILTER_TYPE_NONE;
407                    } else {
408                        filterType = FILTER_TYPE_EMPTY;
409                    }
410                    mListener.onSetFilter(filterType, null, 0);
411                }
412                if (DEBUG) {
413                    Log.d(TAG, "publishResults: " + results.count + " of null [" + constraint);
414                }
415            } else {
416                mLiveResults = (ArrayList<FilterTypeResult>) results.values;
417                if (DEBUG) {
418                    Log.d(TAG, "publishResults: " + results.count + " of " + mLiveResults.size()
419                            + " [" + constraint);
420                }
421            }
422            mLiveResultsCount = results.count;
423
424            if (results.count > 0) {
425                notifyDataSetChanged();
426            } else {
427                notifyDataSetInvalidated();
428            }
429        }
430    }
431}
432