1/*
2 * Copyright (C) 2008 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.mms.ui;
18
19import android.content.Context;
20import android.os.Handler;
21import android.text.InputFilter;
22import android.text.InputType;
23import android.text.Spanned;
24import android.text.method.NumberKeyListener;
25import android.util.AttributeSet;
26import android.view.LayoutInflater;
27import android.view.View;
28import android.view.View.OnClickListener;
29import android.view.View.OnFocusChangeListener;
30import android.view.View.OnLongClickListener;
31import android.widget.TextView;
32import android.widget.LinearLayout;
33import android.widget.EditText;
34
35import com.android.mms.R;
36
37/**
38 * A view for selecting a number
39 *
40 * For a dialog using this view, see {@link android.app.TimePickerDialog}.
41 * @hide
42 */
43public class NumberPicker extends LinearLayout {
44
45    /**
46     * The callback interface used to indicate the number value has been adjusted.
47     */
48    public interface OnChangedListener {
49        /**
50         * @param picker The NumberPicker associated with this listener.
51         * @param oldVal The previous value.
52         * @param newVal The new value.
53         */
54        void onChanged(NumberPicker picker, int oldVal, int newVal);
55    }
56
57    /**
58     * Interface used to format the number into a string for presentation
59     */
60    public interface Formatter {
61        String toString(int value);
62    }
63
64    /*
65     * Use a custom NumberPicker formatting callback to use two-digit
66     * minutes strings like "01".  Keeping a static formatter etc. is the
67     * most efficient way to do this; it avoids creating temporary objects
68     * on every call to format().
69     */
70    public static final NumberPicker.Formatter TWO_DIGIT_FORMATTER =
71            new NumberPicker.Formatter() {
72                final StringBuilder mBuilder = new StringBuilder();
73                final java.util.Formatter mFmt = new java.util.Formatter(mBuilder);
74                final Object[] mArgs = new Object[1];
75                public String toString(int value) {
76                    mArgs[0] = value;
77                    mBuilder.delete(0, mBuilder.length());
78                    mFmt.format("%02d", mArgs);
79                    return mFmt.toString();
80                }
81        };
82
83    private final Handler mHandler;
84    private final Runnable mRunnable = new Runnable() {
85        public void run() {
86            if (mIncrement) {
87                changeCurrent(mCurrent + 1);
88                mHandler.postDelayed(this, mSpeed);
89            } else if (mDecrement) {
90                changeCurrent(mCurrent - 1);
91                mHandler.postDelayed(this, mSpeed);
92            }
93        }
94    };
95
96    private final EditText mText;
97    private final InputFilter mNumberInputFilter;
98
99    private String[] mDisplayedValues;
100
101    /**
102     * Lower value of the range of numbers allowed for the NumberPicker
103     */
104    private int mStart;
105
106    /**
107     * Upper value of the range of numbers allowed for the NumberPicker
108     */
109    private int mEnd;
110
111    /**
112     * Current value of this NumberPicker
113     */
114    private int mCurrent;
115
116    /**
117     * Previous value of this NumberPicker.
118     */
119    private int mPrevious;
120    private OnChangedListener mListener;
121    private Formatter mFormatter;
122    private long mSpeed = 300;
123
124    private boolean mIncrement;
125    private boolean mDecrement;
126
127    /**
128     * Create a new number picker
129     * @param context the application environment
130     */
131    public NumberPicker(Context context) {
132        this(context, null);
133    }
134
135    /**
136     * Create a new number picker
137     * @param context the application environment
138     * @param attrs a collection of attributes
139     */
140    public NumberPicker(Context context, AttributeSet attrs) {
141        super(context, attrs);
142        setOrientation(VERTICAL);
143        LayoutInflater inflater =
144                (LayoutInflater) mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
145        inflater.inflate(R.layout.number_picker, this, true);
146        mHandler = new Handler();
147
148        OnClickListener clickListener = new OnClickListener() {
149            public void onClick(View v) {
150                validateInput(mText);
151                if (!mText.hasFocus()) mText.requestFocus();
152
153                // now perform the increment/decrement
154                if (R.id.increment == v.getId()) {
155                    changeCurrent(mCurrent + 1);
156                } else if (R.id.decrement == v.getId()) {
157                    changeCurrent(mCurrent - 1);
158                }
159            }
160        };
161
162        OnFocusChangeListener focusListener = new OnFocusChangeListener() {
163            public void onFocusChange(View v, boolean hasFocus) {
164
165                /* When focus is lost check that the text field
166                 * has valid values.
167                 */
168                if (!hasFocus) {
169                    validateInput(v);
170                }
171            }
172        };
173
174        OnLongClickListener longClickListener = new OnLongClickListener() {
175            /**
176             * We start the long click here but rely on the {@link NumberPickerButton}
177             * to inform us when the long click has ended.
178             */
179            public boolean onLongClick(View v) {
180                /* The text view may still have focus so clear it's focus which will
181                 * trigger the on focus changed and any typed values to be pulled.
182                 */
183                mText.clearFocus();
184
185                if (R.id.increment == v.getId()) {
186                    mIncrement = true;
187                    mHandler.post(mRunnable);
188                } else if (R.id.decrement == v.getId()) {
189                    mDecrement = true;
190                    mHandler.post(mRunnable);
191                }
192                return true;
193            }
194        };
195
196        InputFilter inputFilter = new NumberPickerInputFilter();
197        mNumberInputFilter = new NumberRangeKeyListener();
198        mIncrementButton = (NumberPickerButton) findViewById(R.id.increment);
199        mIncrementButton.setOnClickListener(clickListener);
200        mIncrementButton.setOnLongClickListener(longClickListener);
201        mIncrementButton.setNumberPicker(this);
202
203        mDecrementButton = (NumberPickerButton) findViewById(R.id.decrement);
204        mDecrementButton.setOnClickListener(clickListener);
205        mDecrementButton.setOnLongClickListener(longClickListener);
206        mDecrementButton.setNumberPicker(this);
207
208        mText = (EditText) findViewById(R.id.timepicker_input);
209        mText.setOnFocusChangeListener(focusListener);
210        mText.setFilters(new InputFilter[] {inputFilter});
211        mText.setRawInputType(InputType.TYPE_CLASS_NUMBER);
212
213        if (!isEnabled()) {
214            setEnabled(false);
215        }
216    }
217
218    /**
219     * Set the enabled state of this view. The interpretation of the enabled
220     * state varies by subclass.
221     *
222     * @param enabled True if this view is enabled, false otherwise.
223     */
224    @Override
225    public void setEnabled(boolean enabled) {
226        super.setEnabled(enabled);
227        mIncrementButton.setEnabled(enabled);
228        mDecrementButton.setEnabled(enabled);
229        mText.setEnabled(enabled);
230    }
231
232    /**
233     * Set the callback that indicates the number has been adjusted by the user.
234     * @param listener the callback, should not be null.
235     */
236    public void setOnChangeListener(OnChangedListener listener) {
237        mListener = listener;
238    }
239
240    /**
241     * Set the formatter that will be used to format the number for presentation
242     * @param formatter the formatter object.  If formatter is null, String.valueOf()
243     * will be used
244     */
245    public void setFormatter(Formatter formatter) {
246        mFormatter = formatter;
247    }
248
249    /**
250     * Set the range of numbers allowed for the number picker. The current
251     * value will be automatically set to the start.
252     *
253     * @param start the start of the range (inclusive)
254     * @param end the end of the range (inclusive)
255     */
256    public void setRange(int start, int end) {
257        setRange(start, end, null/*displayedValues*/);
258    }
259
260    /**
261     * Set the range of numbers allowed for the number picker. The current
262     * value will be automatically set to the start. Also provide a mapping
263     * for values used to display to the user.
264     *
265     * @param start the start of the range (inclusive)
266     * @param end the end of the range (inclusive)
267     * @param displayedValues the values displayed to the user.
268     */
269    public void setRange(int start, int end, String[] displayedValues) {
270        mDisplayedValues = displayedValues;
271        mStart = start;
272        mEnd = end;
273        mCurrent = start;
274        updateView();
275    }
276
277    /**
278     * Set the current value for the number picker.
279     *
280     * @param current the current value the start of the range (inclusive)
281     * @throws IllegalArgumentException when current is not within the range
282     *         of of the number picker
283     */
284    public void setCurrent(int current) {
285        if (current < mStart || current > mEnd) {
286            throw new IllegalArgumentException(
287                    "current should be >= start and <= end");
288        }
289        mCurrent = current;
290        updateView();
291    }
292
293    /**
294     * Sets the speed at which the numbers will scroll when the +/-
295     * buttons are longpressed
296     *
297     * @param speed The speed (in milliseconds) at which the numbers will scroll
298     * default 300ms
299     */
300    public void setSpeed(long speed) {
301        mSpeed = speed;
302    }
303
304    private String formatNumber(int value) {
305        return (mFormatter != null)
306                ? mFormatter.toString(value)
307                : String.valueOf(value);
308    }
309
310    /**
311     * Sets the current value of this NumberPicker, and sets mPrevious to the previous
312     * value.  If current is greater than mEnd less than mStart, the value of mCurrent
313     * is wrapped around.
314     *
315     * Subclasses can override this to change the wrapping behavior
316     *
317     * @param current the new value of the NumberPicker
318     */
319    protected void changeCurrent(int current) {
320        // Wrap around the values if we go past the start or end
321        if (current > mEnd) {
322            current = mStart;
323        } else if (current < mStart) {
324            current = mEnd;
325        }
326        mPrevious = mCurrent;
327        mCurrent = current;
328        notifyChange();
329        updateView();
330    }
331
332    /**
333     * Notifies the listener, if registered, of a change of the value of this
334     * NumberPicker.
335     */
336    private void notifyChange() {
337        if (mListener != null) {
338            mListener.onChanged(this, mPrevious, mCurrent);
339        }
340    }
341
342    /**
343     * Updates the view of this NumberPicker.  If displayValues were specified
344     * in {@link #setRange}, the string corresponding to the index specified by
345     * the current value will be returned.  Otherwise, the formatter specified
346     * in {@link setFormatter} will be used to format the number.
347     */
348    private void updateView() {
349        /* If we don't have displayed values then use the
350         * current number else find the correct value in the
351         * displayed values for the current number.
352         */
353        if (mDisplayedValues == null) {
354            mText.setText(formatNumber(mCurrent));
355        } else {
356            mText.setText(mDisplayedValues[mCurrent - mStart]);
357        }
358        mText.setSelection(mText.getText().length());
359    }
360
361    private void validateCurrentView(CharSequence str) {
362        int val = getSelectedPos(str.toString());
363        if ((val >= mStart) && (val <= mEnd)) {
364            if (mCurrent != val) {
365                mPrevious = mCurrent;
366                mCurrent = val;
367                notifyChange();
368            }
369        }
370        updateView();
371    }
372
373    private void validateInput(View v) {
374        String str = String.valueOf(((TextView) v).getText());
375        if ("".equals(str)) {
376
377            // Restore to the old value as we don't allow empty values
378            updateView();
379        } else {
380
381            // Check the new value and ensure it's in range
382            validateCurrentView(str);
383        }
384    }
385
386    /**
387     * @hide
388     */
389    public void cancelIncrement() {
390        mIncrement = false;
391    }
392
393    /**
394     * @hide
395     */
396    public void cancelDecrement() {
397        mDecrement = false;
398    }
399
400    private static final char[] DIGIT_CHARACTERS = new char[] {
401        '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'
402    };
403
404    private NumberPickerButton mIncrementButton;
405    private NumberPickerButton mDecrementButton;
406
407    private class NumberPickerInputFilter implements InputFilter {
408        public CharSequence filter(CharSequence source, int start, int end,
409                Spanned dest, int dstart, int dend) {
410            if (mDisplayedValues == null) {
411                return mNumberInputFilter.filter(source, start, end, dest, dstart, dend);
412            }
413            CharSequence filtered = String.valueOf(source.subSequence(start, end));
414            String result = String.valueOf(dest.subSequence(0, dstart))
415                    + filtered
416                    + dest.subSequence(dend, dest.length());
417            String str = String.valueOf(result).toLowerCase();
418            for (String val : mDisplayedValues) {
419                val = val.toLowerCase();
420                if (val.startsWith(str)) {
421                    return filtered;
422                }
423            }
424            return "";
425        }
426    }
427
428    private class NumberRangeKeyListener extends NumberKeyListener {
429
430        // XXX This doesn't allow for range limits when controlled by a
431        // soft input method!
432        public int getInputType() {
433            return InputType.TYPE_CLASS_NUMBER;
434        }
435
436        @Override
437        protected char[] getAcceptedChars() {
438            return DIGIT_CHARACTERS;
439        }
440
441        @Override
442        public CharSequence filter(CharSequence source, int start, int end,
443                Spanned dest, int dstart, int dend) {
444
445            CharSequence filtered = super.filter(source, start, end, dest, dstart, dend);
446            if (filtered == null) {
447                filtered = source.subSequence(start, end);
448            }
449
450            String result = String.valueOf(dest.subSequence(0, dstart))
451                    + filtered
452                    + dest.subSequence(dend, dest.length());
453
454            if ("".equals(result)) {
455                return result;
456            }
457            int val = getSelectedPos(result);
458
459            /* Ensure the user can't type in a value greater
460             * than the max allowed. We have to allow less than min
461             * as the user might want to delete some numbers
462             * and then type a new number.
463             */
464            if (val > mEnd) {
465                return "";
466            } else {
467                return filtered;
468            }
469        }
470    }
471
472    private int getSelectedPos(String str) {
473        if (mDisplayedValues == null) {
474            try {
475                return Integer.parseInt(str);
476            } catch (NumberFormatException e) {
477                /* Ignore as if it's not a number we don't care */
478            }
479        } else {
480            for (int i = 0; i < mDisplayedValues.length; i++) {
481
482                /* Don't force the user to type in jan when ja will do */
483                str = str.toLowerCase();
484                if (mDisplayedValues[i].toLowerCase().startsWith(str)) {
485                    return mStart + i;
486                }
487            }
488
489            /* The user might have typed in a number into the month field i.e.
490             * 10 instead of OCT so support that too.
491             */
492            try {
493                return Integer.parseInt(str);
494            } catch (NumberFormatException e) {
495
496                /* Ignore as if it's not a number we don't care */
497            }
498        }
499        return mStart;
500    }
501
502    /**
503     * Returns the current value of the NumberPicker
504     * @return the current value.
505     */
506    public int getCurrent() {
507        return mCurrent;
508    }
509
510    /**
511     * Returns the upper value of the range of the NumberPicker
512     * @return the uppper number of the range.
513     */
514    protected int getEndRange() {
515        return mEnd;
516    }
517
518    /**
519     * Returns the lower value of the range of the NumberPicker
520     * @return the lower number of the range.
521     */
522    protected int getBeginRange() {
523        return mStart;
524    }
525}
526