TimePickerSpinnerDelegate.java revision 271ed9ae870f3a9cbe339161748aa553a87fe2e9
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 android.widget;
18
19import android.animation.Keyframe;
20import android.animation.ObjectAnimator;
21import android.animation.PropertyValuesHolder;
22import android.content.Context;
23import android.content.res.ColorStateList;
24import android.content.res.Configuration;
25import android.content.res.Resources;
26import android.content.res.TypedArray;
27import android.graphics.Color;
28import android.os.Parcel;
29import android.os.Parcelable;
30import android.text.TextUtils;
31import android.text.format.DateFormat;
32import android.text.format.DateUtils;
33import android.util.AttributeSet;
34import android.util.Log;
35import android.view.HapticFeedbackConstants;
36import android.view.KeyCharacterMap;
37import android.view.KeyEvent;
38import android.view.LayoutInflater;
39import android.view.View;
40import android.view.ViewGroup;
41import android.view.accessibility.AccessibilityEvent;
42import android.view.accessibility.AccessibilityNodeInfo;
43
44import com.android.internal.R;
45
46import java.text.DateFormatSymbols;
47import java.util.ArrayList;
48import java.util.Calendar;
49import java.util.Locale;
50
51/**
52 * A view for selecting the time of day, in either 24 hour or AM/PM mode.
53 */
54class TimePickerSpinnerDelegate extends TimePicker.AbstractTimePickerDelegate implements
55        RadialTimePickerView.OnValueSelectedListener {
56
57    private static final String TAG = "TimePickerDelegate";
58
59    // Index used by RadialPickerLayout
60    private static final int HOUR_INDEX = 0;
61    private static final int MINUTE_INDEX = 1;
62
63    // NOT a real index for the purpose of what's showing.
64    private static final int AMPM_INDEX = 2;
65
66    // Also NOT a real index, just used for keyboard mode.
67    private static final int ENABLE_PICKER_INDEX = 3;
68
69    private static final int AM = 0;
70    private static final int PM = 1;
71
72    private static final boolean DEFAULT_ENABLED_STATE = true;
73    private boolean mIsEnabled = DEFAULT_ENABLED_STATE;
74
75    private static final int HOURS_IN_HALF_DAY = 12;
76
77    // Delay in ms before starting the pulse animation
78    private static final int PULSE_ANIMATOR_DELAY = 300;
79
80    // Duration in ms of the pulse animation
81    private static final int PULSE_ANIMATOR_DURATION = 544;
82
83    private TextView mHourView;
84    private TextView mMinuteView;
85    private TextView mAmPmTextView;
86    private RadialTimePickerView mRadialTimePickerView;
87    private TextView mSeparatorView;
88
89    private ViewGroup mLayoutButtons;
90
91    private String mAmText;
92    private String mPmText;
93
94    private boolean mAllowAutoAdvance;
95    private int mInitialHourOfDay;
96    private int mInitialMinute;
97    private boolean mIs24HourView;
98
99    // For hardware IME input.
100    private char mPlaceholderText;
101    private String mDoublePlaceholderText;
102    private String mDeletedKeyFormat;
103    private boolean mInKbMode;
104    private ArrayList<Integer> mTypedTimes = new ArrayList<Integer>();
105    private Node mLegalTimesTree;
106    private int mAmKeyCode;
107    private int mPmKeyCode;
108
109    // For showing the done button when in a Dialog
110    private Button mDoneButton;
111    private boolean mShowDoneButton;
112    private TimePicker.TimePickerDismissCallback mDismissCallback;
113
114    // Accessibility strings.
115    private String mHourPickerDescription;
116    private String mSelectHours;
117    private String mMinutePickerDescription;
118    private String mSelectMinutes;
119
120    private Calendar mTempCalendar;
121
122    public TimePickerSpinnerDelegate(TimePicker delegator, Context context, AttributeSet attrs,
123            int defStyleAttr, int defStyleRes) {
124        super(delegator, context);
125
126        // process style attributes
127        final TypedArray a = mContext.obtainStyledAttributes(attrs,
128                R.styleable.TimePicker, defStyleAttr, defStyleRes);
129        final LayoutInflater inflater = (LayoutInflater) mContext.getSystemService(
130                Context.LAYOUT_INFLATER_SERVICE);
131        final Resources res = mContext.getResources();
132
133        mHourPickerDescription = res.getString(R.string.hour_picker_description);
134        mSelectHours = res.getString(R.string.select_hours);
135        mMinutePickerDescription = res.getString(R.string.minute_picker_description);
136        mSelectMinutes = res.getString(R.string.select_minutes);
137        mAmText = res.getString(R.string.time_picker_am_label);
138        mPmText = res.getString(R.string.time_picker_pm_label);
139
140        final int layoutResourceId = a.getResourceId(R.styleable.TimePicker_internalLayout,
141                R.layout.time_picker_holo);
142        final View mainView = inflater.inflate(layoutResourceId, null);
143        mDelegator.addView(mainView);
144
145        mHourView = (TextView) mainView.findViewById(R.id.hours);
146        mSeparatorView = (TextView) mainView.findViewById(R.id.separator);
147        mMinuteView = (TextView) mainView.findViewById(R.id.minutes);
148        mAmPmTextView = (TextView) mainView.findViewById(R.id.ampm_label);
149        mLayoutButtons = (ViewGroup) mainView.findViewById(R.id.layout_buttons);
150
151        // Set up text appearances from style.
152        final int headerTimeTextAppearance = a.getResourceId(
153                R.styleable.TimePicker_headerTimeTextAppearance, 0);
154        if (headerTimeTextAppearance != 0) {
155            mHourView.setTextAppearance(context, headerTimeTextAppearance);
156            mSeparatorView.setTextAppearance(context, headerTimeTextAppearance);
157            mMinuteView.setTextAppearance(context, headerTimeTextAppearance);
158        }
159
160        final int headerSelectedTextColor = a.getColor(
161                R.styleable.TimePicker_headerSelectedTextColor,
162                res.getColor(R.color.timepicker_default_selector_color_material));
163        mHourView.setTextColor(ColorStateList.addFirstIfMissing(mHourView.getTextColors(),
164                R.attr.state_selected, headerSelectedTextColor));
165        mMinuteView.setTextColor(ColorStateList.addFirstIfMissing(mMinuteView.getTextColors(),
166                R.attr.state_selected, headerSelectedTextColor));
167
168        final int headerAmPmTextAppearance = a.getResourceId(
169                R.styleable.TimePicker_headerAmPmTextAppearance, 0);
170        if (headerAmPmTextAppearance != 0) {
171            mAmPmTextView.setTextAppearance(context, headerAmPmTextAppearance);
172        }
173
174        final int headerBackgroundColor = a.getColor(
175                R.styleable.TimePicker_headerBackgroundColor, Color.TRANSPARENT);
176        if (headerBackgroundColor != Color.TRANSPARENT) {
177            mLayoutButtons.setBackgroundColor(headerBackgroundColor);
178            mainView.findViewById(R.id.time_header).setBackgroundColor(headerBackgroundColor);
179        }
180
181        a.recycle();
182
183        mRadialTimePickerView = (RadialTimePickerView) mainView.findViewById(
184                R.id.radial_picker);
185        mDoneButton = (Button) mainView.findViewById(R.id.done_button);
186
187        setupListeners();
188
189        mAllowAutoAdvance = true;
190
191        // Set up for keyboard mode.
192        mDoublePlaceholderText = res.getString(R.string.time_placeholder);
193        mDeletedKeyFormat = res.getString(R.string.deleted_key);
194        mPlaceholderText = mDoublePlaceholderText.charAt(0);
195        mAmKeyCode = mPmKeyCode = -1;
196        generateLegalTimesTree();
197
198        // Initialize with current time
199        final Calendar calendar = Calendar.getInstance(mCurrentLocale);
200        final int currentHour = calendar.get(Calendar.HOUR_OF_DAY);
201        final int currentMinute = calendar.get(Calendar.MINUTE);
202        initialize(currentHour, currentMinute, false /* 12h */, HOUR_INDEX, false);
203    }
204
205    private void initialize(int hourOfDay, int minute, boolean is24HourView, int index,
206                            boolean showDoneButton) {
207        mInitialHourOfDay = hourOfDay;
208        mInitialMinute = minute;
209        mIs24HourView = is24HourView;
210        mInKbMode = false;
211        mShowDoneButton = showDoneButton;
212        updateUI(index);
213    }
214
215    private void setupListeners() {
216        KeyboardListener keyboardListener = new KeyboardListener();
217        mDelegator.setOnKeyListener(keyboardListener);
218
219        mHourView.setOnKeyListener(keyboardListener);
220        mMinuteView.setOnKeyListener(keyboardListener);
221        mAmPmTextView.setOnKeyListener(keyboardListener);
222        mRadialTimePickerView.setOnValueSelectedListener(this);
223        mRadialTimePickerView.setOnKeyListener(keyboardListener);
224
225        mHourView.setOnClickListener(new View.OnClickListener() {
226            @Override
227            public void onClick(View v) {
228                setCurrentItemShowing(HOUR_INDEX, true, false, true);
229                tryVibrate();
230            }
231        });
232        mMinuteView.setOnClickListener(new View.OnClickListener() {
233            @Override
234            public void onClick(View v) {
235                setCurrentItemShowing(MINUTE_INDEX, true, false, true);
236                tryVibrate();
237            }
238        });
239        mDoneButton.setOnClickListener(new View.OnClickListener() {
240            @Override
241            public void onClick(View v) {
242                if (mInKbMode && isTypedTimeFullyLegal()) {
243                    finishKbMode(false);
244                } else {
245                    tryVibrate();
246                }
247                if (mDismissCallback != null) {
248                    mDismissCallback.dismiss(mDelegator, false, getCurrentHour(),
249                            getCurrentMinute());
250                }
251            }
252        });
253        mDoneButton.setOnKeyListener(keyboardListener);
254    }
255
256    private void updateUI(int index) {
257        // Update RadialPicker values
258        updateRadialPicker(index);
259        // Enable or disable the AM/PM view.
260        updateHeaderAmPm();
261        // Show or hide Done button
262        updateDoneButton();
263        // Update Hour and Minutes
264        updateHeaderHour(mInitialHourOfDay, true);
265        // Update time separator
266        updateHeaderSeparator();
267        // Update Minutes
268        updateHeaderMinute(mInitialMinute);
269        // Invalidate everything
270        mDelegator.invalidate();
271    }
272
273    private void updateRadialPicker(int index) {
274        mRadialTimePickerView.initialize(mInitialHourOfDay, mInitialMinute, mIs24HourView);
275        setCurrentItemShowing(index, false, true, true);
276    }
277
278    private int computeMaxWidthOfNumbers(int max) {
279        TextView tempView = new TextView(mContext);
280        tempView.setTextAppearance(mContext, R.style.TextAppearance_Material_TimePicker_TimeLabel);
281        ViewGroup.LayoutParams lp = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
282                ViewGroup.LayoutParams.WRAP_CONTENT);
283        tempView.setLayoutParams(lp);
284        int maxWidth = 0;
285        for (int minutes = 0; minutes < max; minutes++) {
286            final String text = String.format("%02d", minutes);
287            tempView.setText(text);
288            tempView.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED);
289            maxWidth = Math.max(maxWidth, tempView.getMeasuredWidth());
290        }
291        return maxWidth;
292    }
293
294    private void updateHeaderAmPm() {
295        if (mIs24HourView) {
296            mAmPmTextView.setVisibility(View.GONE);
297        } else {
298            mAmPmTextView.setVisibility(View.VISIBLE);
299            final String bestDateTimePattern = DateFormat.getBestDateTimePattern(mCurrentLocale,
300                    "hm");
301
302            boolean amPmOnLeft = bestDateTimePattern.startsWith("a");
303            if (TextUtils.getLayoutDirectionFromLocale(mCurrentLocale) ==
304                    View.LAYOUT_DIRECTION_RTL) {
305                amPmOnLeft = !amPmOnLeft;
306            }
307
308            RelativeLayout.LayoutParams layoutParams = (RelativeLayout.LayoutParams)
309                    mAmPmTextView.getLayoutParams();
310
311            if (amPmOnLeft) {
312                layoutParams.rightMargin = computeMaxWidthOfNumbers(12 /* for hours */);
313                layoutParams.removeRule(RelativeLayout.RIGHT_OF);
314                layoutParams.addRule(RelativeLayout.LEFT_OF, R.id.separator);
315            } else {
316                layoutParams.leftMargin = computeMaxWidthOfNumbers(60 /* for minutes */);
317                layoutParams.removeRule(RelativeLayout.LEFT_OF);
318                layoutParams.addRule(RelativeLayout.RIGHT_OF, R.id.separator);
319            }
320
321            updateAmPmDisplay(mInitialHourOfDay < 12 ? AM : PM);
322            mAmPmTextView.setOnClickListener(new View.OnClickListener() {
323                @Override
324                public void onClick(View v) {
325                    tryVibrate();
326                    int amOrPm = mRadialTimePickerView.getAmOrPm();
327                    if (amOrPm == AM) {
328                        amOrPm = PM;
329                    } else if (amOrPm == PM){
330                        amOrPm = AM;
331                    }
332                    updateAmPmDisplay(amOrPm);
333                    mRadialTimePickerView.setAmOrPm(amOrPm);
334                }
335            });
336        }
337    }
338
339    private void updateDoneButton() {
340        mLayoutButtons.setVisibility(mShowDoneButton ? View.VISIBLE : View.GONE);
341    }
342
343    /**
344     * Set the current hour.
345     */
346    @Override
347    public void setCurrentHour(Integer currentHour) {
348        if (mInitialHourOfDay == currentHour) {
349            return;
350        }
351        mInitialHourOfDay = currentHour;
352        updateHeaderHour(currentHour, true /* accessibility announce */);
353        updateHeaderAmPm();
354        mRadialTimePickerView.setCurrentHour(currentHour);
355        mRadialTimePickerView.setAmOrPm(mInitialHourOfDay < 12 ? AM : PM);
356        mDelegator.invalidate();
357        onTimeChanged();
358    }
359
360    /**
361     * @return The current hour in the range (0-23).
362     */
363    @Override
364    public Integer getCurrentHour() {
365        int currentHour = mRadialTimePickerView.getCurrentHour();
366        if (mIs24HourView) {
367            return currentHour;
368        } else {
369            switch(mRadialTimePickerView.getAmOrPm()) {
370                case PM:
371                    return (currentHour % HOURS_IN_HALF_DAY) + HOURS_IN_HALF_DAY;
372                case AM:
373                default:
374                    return currentHour % HOURS_IN_HALF_DAY;
375            }
376        }
377    }
378
379    /**
380     * Set the current minute (0-59).
381     */
382    @Override
383    public void setCurrentMinute(Integer currentMinute) {
384        if (mInitialMinute == currentMinute) {
385            return;
386        }
387        mInitialMinute = currentMinute;
388        updateHeaderMinute(currentMinute);
389        mRadialTimePickerView.setCurrentMinute(currentMinute);
390        mDelegator.invalidate();
391        onTimeChanged();
392    }
393
394    /**
395     * @return The current minute.
396     */
397    @Override
398    public Integer getCurrentMinute() {
399        return mRadialTimePickerView.getCurrentMinute();
400    }
401
402    /**
403     * Set whether in 24 hour or AM/PM mode.
404     *
405     * @param is24HourView True = 24 hour mode. False = AM/PM.
406     */
407    @Override
408    public void setIs24HourView(Boolean is24HourView) {
409        if (is24HourView == mIs24HourView) {
410            return;
411        }
412        mIs24HourView = is24HourView;
413        generateLegalTimesTree();
414        int hour = mRadialTimePickerView.getCurrentHour();
415        mInitialHourOfDay = hour;
416        updateHeaderHour(hour, false /* no accessibility announce */);
417        updateHeaderAmPm();
418        updateRadialPicker(mRadialTimePickerView.getCurrentItemShowing());
419        mDelegator.invalidate();
420    }
421
422    /**
423     * @return true if this is in 24 hour view else false.
424     */
425    @Override
426    public boolean is24HourView() {
427        return mIs24HourView;
428    }
429
430    @Override
431    public void setOnTimeChangedListener(TimePicker.OnTimeChangedListener callback) {
432        mOnTimeChangedListener = callback;
433    }
434
435    @Override
436    public void setEnabled(boolean enabled) {
437        mHourView.setEnabled(enabled);
438        mMinuteView.setEnabled(enabled);
439        mAmPmTextView.setEnabled(enabled);
440        mRadialTimePickerView.setEnabled(enabled);
441        mIsEnabled = enabled;
442    }
443
444    @Override
445    public boolean isEnabled() {
446        return mIsEnabled;
447    }
448
449    @Override
450    public void setShowDoneButton(boolean showDoneButton) {
451        mShowDoneButton = showDoneButton;
452        updateDoneButton();
453    }
454
455    @Override
456    public void setDismissCallback(TimePicker.TimePickerDismissCallback callback) {
457        mDismissCallback = callback;
458    }
459
460    @Override
461    public int getBaseline() {
462        // does not support baseline alignment
463        return -1;
464    }
465
466    @Override
467    public void onConfigurationChanged(Configuration newConfig) {
468        updateUI(mRadialTimePickerView.getCurrentItemShowing());
469    }
470
471    @Override
472    public Parcelable onSaveInstanceState(Parcelable superState) {
473        return new SavedState(superState, getCurrentHour(), getCurrentMinute(),
474                is24HourView(), inKbMode(), getTypedTimes(), getCurrentItemShowing(),
475                isShowDoneButton());
476    }
477
478    @Override
479    public void onRestoreInstanceState(Parcelable state) {
480        SavedState ss = (SavedState) state;
481        setInKbMode(ss.inKbMode());
482        setTypedTimes(ss.getTypesTimes());
483        initialize(ss.getHour(), ss.getMinute(), ss.is24HourMode(), ss.getCurrentItemShowing(),
484                ss.isShowDoneButton());
485        mRadialTimePickerView.invalidate();
486        if (mInKbMode) {
487            tryStartingKbMode(-1);
488            mHourView.invalidate();
489        }
490    }
491
492    @Override
493    public void setCurrentLocale(Locale locale) {
494        super.setCurrentLocale(locale);
495        mTempCalendar = Calendar.getInstance(locale);
496    }
497
498    @Override
499    public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) {
500        onPopulateAccessibilityEvent(event);
501        return true;
502    }
503
504    @Override
505    public void onPopulateAccessibilityEvent(AccessibilityEvent event) {
506        int flags = DateUtils.FORMAT_SHOW_TIME;
507        if (mIs24HourView) {
508            flags |= DateUtils.FORMAT_24HOUR;
509        } else {
510            flags |= DateUtils.FORMAT_12HOUR;
511        }
512        mTempCalendar.set(Calendar.HOUR_OF_DAY, getCurrentHour());
513        mTempCalendar.set(Calendar.MINUTE, getCurrentMinute());
514        String selectedDate = DateUtils.formatDateTime(mContext,
515                mTempCalendar.getTimeInMillis(), flags);
516        event.getText().add(selectedDate);
517    }
518
519    @Override
520    public void onInitializeAccessibilityEvent(AccessibilityEvent event) {
521        event.setClassName(TimePicker.class.getName());
522    }
523
524    @Override
525    public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
526        info.setClassName(TimePicker.class.getName());
527    }
528
529    /**
530     * Set whether in keyboard mode or not.
531     *
532     * @param inKbMode True means in keyboard mode.
533     */
534    private void setInKbMode(boolean inKbMode) {
535        mInKbMode = inKbMode;
536    }
537
538    /**
539     * @return true if in keyboard mode
540     */
541    private boolean inKbMode() {
542        return mInKbMode;
543    }
544
545    private void setTypedTimes(ArrayList<Integer> typeTimes) {
546        mTypedTimes = typeTimes;
547    }
548
549    /**
550     * @return an array of typed times
551     */
552    private ArrayList<Integer> getTypedTimes() {
553        return mTypedTimes;
554    }
555
556    /**
557     * @return the index of the current item showing
558     */
559    private int getCurrentItemShowing() {
560        return mRadialTimePickerView.getCurrentItemShowing();
561    }
562
563    @Override
564    public boolean isShowDoneButton() {
565        return mShowDoneButton;
566    }
567
568    /**
569     * Propagate the time change
570     */
571    private void onTimeChanged() {
572        mDelegator.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED);
573        if (mOnTimeChangedListener != null) {
574            mOnTimeChangedListener.onTimeChanged(mDelegator,
575                    getCurrentHour(), getCurrentMinute());
576        }
577    }
578
579    /**
580     * Used to save / restore state of time picker
581     */
582    private static class SavedState extends View.BaseSavedState {
583
584        private final int mHour;
585        private final int mMinute;
586        private final boolean mIs24HourMode;
587        private final boolean mInKbMode;
588        private final ArrayList<Integer> mTypedTimes;
589        private final int mCurrentItemShowing;
590        private final boolean mShowDoneButton;
591
592        private SavedState(Parcelable superState, int hour, int minute, boolean is24HourMode,
593                           boolean isKbMode, ArrayList<Integer> typedTimes,
594                           int currentItemShowing, boolean showDoneButton) {
595            super(superState);
596            mHour = hour;
597            mMinute = minute;
598            mIs24HourMode = is24HourMode;
599            mInKbMode = isKbMode;
600            mTypedTimes = typedTimes;
601            mCurrentItemShowing = currentItemShowing;
602            mShowDoneButton = showDoneButton;
603        }
604
605        private SavedState(Parcel in) {
606            super(in);
607            mHour = in.readInt();
608            mMinute = in.readInt();
609            mIs24HourMode = (in.readInt() == 1);
610            mInKbMode = (in.readInt() == 1);
611            mTypedTimes = in.readArrayList(getClass().getClassLoader());
612            mCurrentItemShowing = in.readInt();
613            mShowDoneButton = (in.readInt() == 1);
614        }
615
616        public int getHour() {
617            return mHour;
618        }
619
620        public int getMinute() {
621            return mMinute;
622        }
623
624        public boolean is24HourMode() {
625            return mIs24HourMode;
626        }
627
628        public boolean inKbMode() {
629            return mInKbMode;
630        }
631
632        public ArrayList<Integer> getTypesTimes() {
633            return mTypedTimes;
634        }
635
636        public int getCurrentItemShowing() {
637            return mCurrentItemShowing;
638        }
639
640        public boolean isShowDoneButton() {
641            return mShowDoneButton;
642        }
643
644        @Override
645        public void writeToParcel(Parcel dest, int flags) {
646            super.writeToParcel(dest, flags);
647            dest.writeInt(mHour);
648            dest.writeInt(mMinute);
649            dest.writeInt(mIs24HourMode ? 1 : 0);
650            dest.writeInt(mInKbMode ? 1 : 0);
651            dest.writeList(mTypedTimes);
652            dest.writeInt(mCurrentItemShowing);
653            dest.writeInt(mShowDoneButton ? 1 : 0);
654        }
655
656        @SuppressWarnings({"unused", "hiding"})
657        public static final Parcelable.Creator<SavedState> CREATOR = new Creator<SavedState>() {
658            public SavedState createFromParcel(Parcel in) {
659                return new SavedState(in);
660            }
661
662            public SavedState[] newArray(int size) {
663                return new SavedState[size];
664            }
665        };
666    }
667
668    private void tryVibrate() {
669        mDelegator.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK);
670    }
671
672    private void updateAmPmDisplay(int amOrPm) {
673        if (amOrPm == AM) {
674            mAmPmTextView.setText(mAmText);
675            mRadialTimePickerView.announceForAccessibility(mAmText);
676        } else if (amOrPm == PM){
677            mAmPmTextView.setText(mPmText);
678            mRadialTimePickerView.announceForAccessibility(mPmText);
679        } else {
680            mAmPmTextView.setText(mDoublePlaceholderText);
681        }
682    }
683
684    /**
685     * Called by the picker for updating the header display.
686     */
687    @Override
688    public void onValueSelected(int pickerIndex, int newValue, boolean autoAdvance) {
689        if (pickerIndex == HOUR_INDEX) {
690            updateHeaderHour(newValue, false);
691            String announcement = String.format("%d", newValue);
692            if (mAllowAutoAdvance && autoAdvance) {
693                setCurrentItemShowing(MINUTE_INDEX, true, true, false);
694                announcement += ". " + mSelectMinutes;
695            } else {
696                mRadialTimePickerView.setContentDescription(
697                        mHourPickerDescription + ": " + newValue);
698            }
699
700            mRadialTimePickerView.announceForAccessibility(announcement);
701        } else if (pickerIndex == MINUTE_INDEX){
702            updateHeaderMinute(newValue);
703            mRadialTimePickerView.setContentDescription(mMinutePickerDescription + ": " + newValue);
704        } else if (pickerIndex == AMPM_INDEX) {
705            updateAmPmDisplay(newValue);
706        } else if (pickerIndex == ENABLE_PICKER_INDEX) {
707            if (!isTypedTimeFullyLegal()) {
708                mTypedTimes.clear();
709            }
710            finishKbMode(true);
711        }
712    }
713
714    private void updateHeaderHour(int value, boolean announce) {
715        final String bestDateTimePattern = DateFormat.getBestDateTimePattern(mCurrentLocale,
716                (mIs24HourView) ? "Hm" : "hm");
717        final int lengthPattern = bestDateTimePattern.length();
718        boolean hourWithTwoDigit = false;
719        char hourFormat = '\0';
720        // Check if the returned pattern is single or double 'H', 'h', 'K', 'k'. We also save
721        // the hour format that we found.
722        for (int i = 0; i < lengthPattern; i++) {
723            final char c = bestDateTimePattern.charAt(i);
724            if (c == 'H' || c == 'h' || c == 'K' || c == 'k') {
725                hourFormat = c;
726                if (i + 1 < lengthPattern && c == bestDateTimePattern.charAt(i + 1)) {
727                    hourWithTwoDigit = true;
728                }
729                break;
730            }
731        }
732        final String format;
733        if (hourWithTwoDigit) {
734            format = "%02d";
735        } else {
736            format = "%d";
737        }
738        if (mIs24HourView) {
739            // 'k' means 1-24 hour
740            if (hourFormat == 'k' && value == 0) {
741                value = 24;
742            }
743        } else {
744            // 'K' means 0-11 hour
745            value = modulo12(value, hourFormat == 'K');
746        }
747        CharSequence text = String.format(format, value);
748        mHourView.setText(text);
749        if (announce) {
750            mRadialTimePickerView.announceForAccessibility(text);
751        }
752    }
753
754    private static int modulo12(int n, boolean startWithZero) {
755        int value = n % 12;
756        if (value == 0 && !startWithZero) {
757            value = 12;
758        }
759        return value;
760    }
761
762    /**
763     * The time separator is defined in the Unicode CLDR and cannot be supposed to be ":".
764     *
765     * See http://unicode.org/cldr/trac/browser/trunk/common/main
766     *
767     * We pass the correct "skeleton" depending on 12 or 24 hours view and then extract the
768     * separator as the character which is just after the hour marker in the returned pattern.
769     */
770    private void updateHeaderSeparator() {
771        final String bestDateTimePattern = DateFormat.getBestDateTimePattern(mCurrentLocale,
772                (mIs24HourView) ? "Hm" : "hm");
773        final String separatorText;
774        // See http://www.unicode.org/reports/tr35/tr35-dates.html for hour formats
775        final char[] hourFormats = {'H', 'h', 'K', 'k'};
776        int hIndex = lastIndexOfAny(bestDateTimePattern, hourFormats);
777        if (hIndex == -1) {
778            // Default case
779            separatorText = ":";
780        } else {
781            separatorText = Character.toString(bestDateTimePattern.charAt(hIndex + 1));
782        }
783        mSeparatorView.setText(separatorText);
784    }
785
786    static private int lastIndexOfAny(String str, char[] any) {
787        final int lengthAny = any.length;
788        if (lengthAny > 0) {
789            for (int i = str.length() - 1; i >= 0; i--) {
790                char c = str.charAt(i);
791                for (int j = 0; j < lengthAny; j++) {
792                    if (c == any[j]) {
793                        return i;
794                    }
795                }
796            }
797        }
798        return -1;
799    }
800
801    private void updateHeaderMinute(int value) {
802        if (value == 60) {
803            value = 0;
804        }
805        CharSequence text = String.format(mCurrentLocale, "%02d", value);
806        mRadialTimePickerView.announceForAccessibility(text);
807        mMinuteView.setText(text);
808    }
809
810    /**
811     * Show either Hours or Minutes.
812     */
813    private void setCurrentItemShowing(int index, boolean animateCircle, boolean delayLabelAnimate,
814                                       boolean announce) {
815        mRadialTimePickerView.setCurrentItemShowing(index, animateCircle);
816
817        TextView labelToAnimate;
818        if (index == HOUR_INDEX) {
819            int hours = mRadialTimePickerView.getCurrentHour();
820            if (!mIs24HourView) {
821                hours = hours % 12;
822            }
823            mRadialTimePickerView.setContentDescription(mHourPickerDescription + ": " + hours);
824            if (announce) {
825                mRadialTimePickerView.announceForAccessibility(mSelectHours);
826            }
827            labelToAnimate = mHourView;
828        } else {
829            int minutes = mRadialTimePickerView.getCurrentMinute();
830            mRadialTimePickerView.setContentDescription(mMinutePickerDescription + ": " + minutes);
831            if (announce) {
832                mRadialTimePickerView.announceForAccessibility(mSelectMinutes);
833            }
834            labelToAnimate = mMinuteView;
835        }
836
837        mHourView.setSelected(index == HOUR_INDEX);
838        mMinuteView.setSelected(index == MINUTE_INDEX);
839
840        ObjectAnimator pulseAnimator = getPulseAnimator(labelToAnimate, 0.85f, 1.1f);
841        if (delayLabelAnimate) {
842            pulseAnimator.setStartDelay(PULSE_ANIMATOR_DELAY);
843        }
844        pulseAnimator.start();
845    }
846
847    /**
848     * For keyboard mode, processes key events.
849     *
850     * @param keyCode the pressed key.
851     *
852     * @return true if the key was successfully processed, false otherwise.
853     */
854    private boolean processKeyUp(int keyCode) {
855        if (keyCode == KeyEvent.KEYCODE_ESCAPE || keyCode == KeyEvent.KEYCODE_BACK) {
856            if (mDismissCallback != null) {
857                mDismissCallback.dismiss(mDelegator, true, getCurrentHour(), getCurrentMinute());
858            }
859            return true;
860        } else if (keyCode == KeyEvent.KEYCODE_TAB) {
861            if(mInKbMode) {
862                if (isTypedTimeFullyLegal()) {
863                    finishKbMode(true);
864                }
865                return true;
866            }
867        } else if (keyCode == KeyEvent.KEYCODE_ENTER) {
868            if (mInKbMode) {
869                if (!isTypedTimeFullyLegal()) {
870                    return true;
871                }
872                finishKbMode(false);
873            }
874            if (mOnTimeChangedListener != null) {
875                mOnTimeChangedListener.onTimeChanged(mDelegator,
876                        mRadialTimePickerView.getCurrentHour(),
877                        mRadialTimePickerView.getCurrentMinute());
878            }
879            if (mDismissCallback != null) {
880                mDismissCallback.dismiss(mDelegator, false, getCurrentHour(), getCurrentMinute());
881            }
882            return true;
883        } else if (keyCode == KeyEvent.KEYCODE_DEL) {
884            if (mInKbMode) {
885                if (!mTypedTimes.isEmpty()) {
886                    int deleted = deleteLastTypedKey();
887                    String deletedKeyStr;
888                    if (deleted == getAmOrPmKeyCode(AM)) {
889                        deletedKeyStr = mAmText;
890                    } else if (deleted == getAmOrPmKeyCode(PM)) {
891                        deletedKeyStr = mPmText;
892                    } else {
893                        deletedKeyStr = String.format("%d", getValFromKeyCode(deleted));
894                    }
895                    mRadialTimePickerView.announceForAccessibility(
896                            String.format(mDeletedKeyFormat, deletedKeyStr));
897                    updateDisplay(true);
898                }
899            }
900        } else if (keyCode == KeyEvent.KEYCODE_0 || keyCode == KeyEvent.KEYCODE_1
901                || keyCode == KeyEvent.KEYCODE_2 || keyCode == KeyEvent.KEYCODE_3
902                || keyCode == KeyEvent.KEYCODE_4 || keyCode == KeyEvent.KEYCODE_5
903                || keyCode == KeyEvent.KEYCODE_6 || keyCode == KeyEvent.KEYCODE_7
904                || keyCode == KeyEvent.KEYCODE_8 || keyCode == KeyEvent.KEYCODE_9
905                || (!mIs24HourView &&
906                (keyCode == getAmOrPmKeyCode(AM) || keyCode == getAmOrPmKeyCode(PM)))) {
907            if (!mInKbMode) {
908                if (mRadialTimePickerView == null) {
909                    // Something's wrong, because time picker should definitely not be null.
910                    Log.e(TAG, "Unable to initiate keyboard mode, TimePicker was null.");
911                    return true;
912                }
913                mTypedTimes.clear();
914                tryStartingKbMode(keyCode);
915                return true;
916            }
917            // We're already in keyboard mode.
918            if (addKeyIfLegal(keyCode)) {
919                updateDisplay(false);
920            }
921            return true;
922        }
923        return false;
924    }
925
926    /**
927     * Try to start keyboard mode with the specified key.
928     *
929     * @param keyCode The key to use as the first press. Keyboard mode will not be started if the
930     * key is not legal to start with. Or, pass in -1 to get into keyboard mode without a starting
931     * key.
932     */
933    private void tryStartingKbMode(int keyCode) {
934        if (keyCode == -1 || addKeyIfLegal(keyCode)) {
935            mInKbMode = true;
936            mDoneButton.setEnabled(false);
937            updateDisplay(false);
938            mRadialTimePickerView.setInputEnabled(false);
939        }
940    }
941
942    private boolean addKeyIfLegal(int keyCode) {
943        // If we're in 24hour mode, we'll need to check if the input is full. If in AM/PM mode,
944        // we'll need to see if AM/PM have been typed.
945        if ((mIs24HourView && mTypedTimes.size() == 4) ||
946                (!mIs24HourView && isTypedTimeFullyLegal())) {
947            return false;
948        }
949
950        mTypedTimes.add(keyCode);
951        if (!isTypedTimeLegalSoFar()) {
952            deleteLastTypedKey();
953            return false;
954        }
955
956        int val = getValFromKeyCode(keyCode);
957        mRadialTimePickerView.announceForAccessibility(String.format("%d", val));
958        // Automatically fill in 0's if AM or PM was legally entered.
959        if (isTypedTimeFullyLegal()) {
960            if (!mIs24HourView && mTypedTimes.size() <= 3) {
961                mTypedTimes.add(mTypedTimes.size() - 1, KeyEvent.KEYCODE_0);
962                mTypedTimes.add(mTypedTimes.size() - 1, KeyEvent.KEYCODE_0);
963            }
964            mDoneButton.setEnabled(true);
965        }
966
967        return true;
968    }
969
970    /**
971     * Traverse the tree to see if the keys that have been typed so far are legal as is,
972     * or may become legal as more keys are typed (excluding backspace).
973     */
974    private boolean isTypedTimeLegalSoFar() {
975        Node node = mLegalTimesTree;
976        for (int keyCode : mTypedTimes) {
977            node = node.canReach(keyCode);
978            if (node == null) {
979                return false;
980            }
981        }
982        return true;
983    }
984
985    /**
986     * Check if the time that has been typed so far is completely legal, as is.
987     */
988    private boolean isTypedTimeFullyLegal() {
989        if (mIs24HourView) {
990            // For 24-hour mode, the time is legal if the hours and minutes are each legal. Note:
991            // getEnteredTime() will ONLY call isTypedTimeFullyLegal() when NOT in 24hour mode.
992            int[] values = getEnteredTime(null);
993            return (values[0] >= 0 && values[1] >= 0 && values[1] < 60);
994        } else {
995            // For AM/PM mode, the time is legal if it contains an AM or PM, as those can only be
996            // legally added at specific times based on the tree's algorithm.
997            return (mTypedTimes.contains(getAmOrPmKeyCode(AM)) ||
998                    mTypedTimes.contains(getAmOrPmKeyCode(PM)));
999        }
1000    }
1001
1002    private int deleteLastTypedKey() {
1003        int deleted = mTypedTimes.remove(mTypedTimes.size() - 1);
1004        if (!isTypedTimeFullyLegal()) {
1005            mDoneButton.setEnabled(false);
1006        }
1007        return deleted;
1008    }
1009
1010    /**
1011     * Get out of keyboard mode. If there is nothing in typedTimes, revert to TimePicker's time.
1012     * @param updateDisplays If true, update the displays with the relevant time.
1013     */
1014    private void finishKbMode(boolean updateDisplays) {
1015        mInKbMode = false;
1016        if (!mTypedTimes.isEmpty()) {
1017            int values[] = getEnteredTime(null);
1018            mRadialTimePickerView.setCurrentHour(values[0]);
1019            mRadialTimePickerView.setCurrentMinute(values[1]);
1020            if (!mIs24HourView) {
1021                mRadialTimePickerView.setAmOrPm(values[2]);
1022            }
1023            mTypedTimes.clear();
1024        }
1025        if (updateDisplays) {
1026            updateDisplay(false);
1027            mRadialTimePickerView.setInputEnabled(true);
1028        }
1029    }
1030
1031    /**
1032     * Update the hours, minutes, and AM/PM displays with the typed times. If the typedTimes is
1033     * empty, either show an empty display (filled with the placeholder text), or update from the
1034     * timepicker's values.
1035     *
1036     * @param allowEmptyDisplay if true, then if the typedTimes is empty, use the placeholder text.
1037     * Otherwise, revert to the timepicker's values.
1038     */
1039    private void updateDisplay(boolean allowEmptyDisplay) {
1040        if (!allowEmptyDisplay && mTypedTimes.isEmpty()) {
1041            int hour = mRadialTimePickerView.getCurrentHour();
1042            int minute = mRadialTimePickerView.getCurrentMinute();
1043            updateHeaderHour(hour, true);
1044            updateHeaderMinute(minute);
1045            if (!mIs24HourView) {
1046                updateAmPmDisplay(hour < 12 ? AM : PM);
1047            }
1048            setCurrentItemShowing(mRadialTimePickerView.getCurrentItemShowing(), true, true, true);
1049            mDoneButton.setEnabled(true);
1050        } else {
1051            boolean[] enteredZeros = {false, false};
1052            int[] values = getEnteredTime(enteredZeros);
1053            String hourFormat = enteredZeros[0] ? "%02d" : "%2d";
1054            String minuteFormat = (enteredZeros[1]) ? "%02d" : "%2d";
1055            String hourStr = (values[0] == -1) ? mDoublePlaceholderText :
1056                    String.format(hourFormat, values[0]).replace(' ', mPlaceholderText);
1057            String minuteStr = (values[1] == -1) ? mDoublePlaceholderText :
1058                    String.format(minuteFormat, values[1]).replace(' ', mPlaceholderText);
1059            mHourView.setText(hourStr);
1060            mHourView.setSelected(false);
1061            mMinuteView.setText(minuteStr);
1062            mMinuteView.setSelected(false);
1063            if (!mIs24HourView) {
1064                updateAmPmDisplay(values[2]);
1065            }
1066        }
1067    }
1068
1069    private int getValFromKeyCode(int keyCode) {
1070        switch (keyCode) {
1071            case KeyEvent.KEYCODE_0:
1072                return 0;
1073            case KeyEvent.KEYCODE_1:
1074                return 1;
1075            case KeyEvent.KEYCODE_2:
1076                return 2;
1077            case KeyEvent.KEYCODE_3:
1078                return 3;
1079            case KeyEvent.KEYCODE_4:
1080                return 4;
1081            case KeyEvent.KEYCODE_5:
1082                return 5;
1083            case KeyEvent.KEYCODE_6:
1084                return 6;
1085            case KeyEvent.KEYCODE_7:
1086                return 7;
1087            case KeyEvent.KEYCODE_8:
1088                return 8;
1089            case KeyEvent.KEYCODE_9:
1090                return 9;
1091            default:
1092                return -1;
1093        }
1094    }
1095
1096    /**
1097     * Get the currently-entered time, as integer values of the hours and minutes typed.
1098     *
1099     * @param enteredZeros A size-2 boolean array, which the caller should initialize, and which
1100     * may then be used for the caller to know whether zeros had been explicitly entered as either
1101     * hours of minutes. This is helpful for deciding whether to show the dashes, or actual 0's.
1102     *
1103     * @return A size-3 int array. The first value will be the hours, the second value will be the
1104     * minutes, and the third will be either AM or PM.
1105     */
1106    private int[] getEnteredTime(boolean[] enteredZeros) {
1107        int amOrPm = -1;
1108        int startIndex = 1;
1109        if (!mIs24HourView && isTypedTimeFullyLegal()) {
1110            int keyCode = mTypedTimes.get(mTypedTimes.size() - 1);
1111            if (keyCode == getAmOrPmKeyCode(AM)) {
1112                amOrPm = AM;
1113            } else if (keyCode == getAmOrPmKeyCode(PM)){
1114                amOrPm = PM;
1115            }
1116            startIndex = 2;
1117        }
1118        int minute = -1;
1119        int hour = -1;
1120        for (int i = startIndex; i <= mTypedTimes.size(); i++) {
1121            int val = getValFromKeyCode(mTypedTimes.get(mTypedTimes.size() - i));
1122            if (i == startIndex) {
1123                minute = val;
1124            } else if (i == startIndex+1) {
1125                minute += 10 * val;
1126                if (enteredZeros != null && val == 0) {
1127                    enteredZeros[1] = true;
1128                }
1129            } else if (i == startIndex+2) {
1130                hour = val;
1131            } else if (i == startIndex+3) {
1132                hour += 10 * val;
1133                if (enteredZeros != null && val == 0) {
1134                    enteredZeros[0] = true;
1135                }
1136            }
1137        }
1138
1139        return new int[] { hour, minute, amOrPm };
1140    }
1141
1142    /**
1143     * Get the keycode value for AM and PM in the current language.
1144     */
1145    private int getAmOrPmKeyCode(int amOrPm) {
1146        // Cache the codes.
1147        if (mAmKeyCode == -1 || mPmKeyCode == -1) {
1148            // Find the first character in the AM/PM text that is unique.
1149            KeyCharacterMap kcm = KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD);
1150            char amChar;
1151            char pmChar;
1152            for (int i = 0; i < Math.max(mAmText.length(), mPmText.length()); i++) {
1153                amChar = mAmText.toLowerCase(mCurrentLocale).charAt(i);
1154                pmChar = mPmText.toLowerCase(mCurrentLocale).charAt(i);
1155                if (amChar != pmChar) {
1156                    KeyEvent[] events = kcm.getEvents(new char[]{amChar, pmChar});
1157                    // There should be 4 events: a down and up for both AM and PM.
1158                    if (events != null && events.length == 4) {
1159                        mAmKeyCode = events[0].getKeyCode();
1160                        mPmKeyCode = events[2].getKeyCode();
1161                    } else {
1162                        Log.e(TAG, "Unable to find keycodes for AM and PM.");
1163                    }
1164                    break;
1165                }
1166            }
1167        }
1168        if (amOrPm == AM) {
1169            return mAmKeyCode;
1170        } else if (amOrPm == PM) {
1171            return mPmKeyCode;
1172        }
1173
1174        return -1;
1175    }
1176
1177    /**
1178     * Create a tree for deciding what keys can legally be typed.
1179     */
1180    private void generateLegalTimesTree() {
1181        // Create a quick cache of numbers to their keycodes.
1182        final int k0 = KeyEvent.KEYCODE_0;
1183        final int k1 = KeyEvent.KEYCODE_1;
1184        final int k2 = KeyEvent.KEYCODE_2;
1185        final int k3 = KeyEvent.KEYCODE_3;
1186        final int k4 = KeyEvent.KEYCODE_4;
1187        final int k5 = KeyEvent.KEYCODE_5;
1188        final int k6 = KeyEvent.KEYCODE_6;
1189        final int k7 = KeyEvent.KEYCODE_7;
1190        final int k8 = KeyEvent.KEYCODE_8;
1191        final int k9 = KeyEvent.KEYCODE_9;
1192
1193        // The root of the tree doesn't contain any numbers.
1194        mLegalTimesTree = new Node();
1195        if (mIs24HourView) {
1196            // We'll be re-using these nodes, so we'll save them.
1197            Node minuteFirstDigit = new Node(k0, k1, k2, k3, k4, k5);
1198            Node minuteSecondDigit = new Node(k0, k1, k2, k3, k4, k5, k6, k7, k8, k9);
1199            // The first digit must be followed by the second digit.
1200            minuteFirstDigit.addChild(minuteSecondDigit);
1201
1202            // The first digit may be 0-1.
1203            Node firstDigit = new Node(k0, k1);
1204            mLegalTimesTree.addChild(firstDigit);
1205
1206            // When the first digit is 0-1, the second digit may be 0-5.
1207            Node secondDigit = new Node(k0, k1, k2, k3, k4, k5);
1208            firstDigit.addChild(secondDigit);
1209            // We may now be followed by the first minute digit. E.g. 00:09, 15:58.
1210            secondDigit.addChild(minuteFirstDigit);
1211
1212            // When the first digit is 0-1, and the second digit is 0-5, the third digit may be 6-9.
1213            Node thirdDigit = new Node(k6, k7, k8, k9);
1214            // The time must now be finished. E.g. 0:55, 1:08.
1215            secondDigit.addChild(thirdDigit);
1216
1217            // When the first digit is 0-1, the second digit may be 6-9.
1218            secondDigit = new Node(k6, k7, k8, k9);
1219            firstDigit.addChild(secondDigit);
1220            // We must now be followed by the first minute digit. E.g. 06:50, 18:20.
1221            secondDigit.addChild(minuteFirstDigit);
1222
1223            // The first digit may be 2.
1224            firstDigit = new Node(k2);
1225            mLegalTimesTree.addChild(firstDigit);
1226
1227            // When the first digit is 2, the second digit may be 0-3.
1228            secondDigit = new Node(k0, k1, k2, k3);
1229            firstDigit.addChild(secondDigit);
1230            // We must now be followed by the first minute digit. E.g. 20:50, 23:09.
1231            secondDigit.addChild(minuteFirstDigit);
1232
1233            // When the first digit is 2, the second digit may be 4-5.
1234            secondDigit = new Node(k4, k5);
1235            firstDigit.addChild(secondDigit);
1236            // We must now be followd by the last minute digit. E.g. 2:40, 2:53.
1237            secondDigit.addChild(minuteSecondDigit);
1238
1239            // The first digit may be 3-9.
1240            firstDigit = new Node(k3, k4, k5, k6, k7, k8, k9);
1241            mLegalTimesTree.addChild(firstDigit);
1242            // We must now be followed by the first minute digit. E.g. 3:57, 8:12.
1243            firstDigit.addChild(minuteFirstDigit);
1244        } else {
1245            // We'll need to use the AM/PM node a lot.
1246            // Set up AM and PM to respond to "a" and "p".
1247            Node ampm = new Node(getAmOrPmKeyCode(AM), getAmOrPmKeyCode(PM));
1248
1249            // The first hour digit may be 1.
1250            Node firstDigit = new Node(k1);
1251            mLegalTimesTree.addChild(firstDigit);
1252            // We'll allow quick input of on-the-hour times. E.g. 1pm.
1253            firstDigit.addChild(ampm);
1254
1255            // When the first digit is 1, the second digit may be 0-2.
1256            Node secondDigit = new Node(k0, k1, k2);
1257            firstDigit.addChild(secondDigit);
1258            // Also for quick input of on-the-hour times. E.g. 10pm, 12am.
1259            secondDigit.addChild(ampm);
1260
1261            // When the first digit is 1, and the second digit is 0-2, the third digit may be 0-5.
1262            Node thirdDigit = new Node(k0, k1, k2, k3, k4, k5);
1263            secondDigit.addChild(thirdDigit);
1264            // The time may be finished now. E.g. 1:02pm, 1:25am.
1265            thirdDigit.addChild(ampm);
1266
1267            // When the first digit is 1, the second digit is 0-2, and the third digit is 0-5,
1268            // the fourth digit may be 0-9.
1269            Node fourthDigit = new Node(k0, k1, k2, k3, k4, k5, k6, k7, k8, k9);
1270            thirdDigit.addChild(fourthDigit);
1271            // The time must be finished now. E.g. 10:49am, 12:40pm.
1272            fourthDigit.addChild(ampm);
1273
1274            // When the first digit is 1, and the second digit is 0-2, the third digit may be 6-9.
1275            thirdDigit = new Node(k6, k7, k8, k9);
1276            secondDigit.addChild(thirdDigit);
1277            // The time must be finished now. E.g. 1:08am, 1:26pm.
1278            thirdDigit.addChild(ampm);
1279
1280            // When the first digit is 1, the second digit may be 3-5.
1281            secondDigit = new Node(k3, k4, k5);
1282            firstDigit.addChild(secondDigit);
1283
1284            // When the first digit is 1, and the second digit is 3-5, the third digit may be 0-9.
1285            thirdDigit = new Node(k0, k1, k2, k3, k4, k5, k6, k7, k8, k9);
1286            secondDigit.addChild(thirdDigit);
1287            // The time must be finished now. E.g. 1:39am, 1:50pm.
1288            thirdDigit.addChild(ampm);
1289
1290            // The hour digit may be 2-9.
1291            firstDigit = new Node(k2, k3, k4, k5, k6, k7, k8, k9);
1292            mLegalTimesTree.addChild(firstDigit);
1293            // We'll allow quick input of on-the-hour-times. E.g. 2am, 5pm.
1294            firstDigit.addChild(ampm);
1295
1296            // When the first digit is 2-9, the second digit may be 0-5.
1297            secondDigit = new Node(k0, k1, k2, k3, k4, k5);
1298            firstDigit.addChild(secondDigit);
1299
1300            // When the first digit is 2-9, and the second digit is 0-5, the third digit may be 0-9.
1301            thirdDigit = new Node(k0, k1, k2, k3, k4, k5, k6, k7, k8, k9);
1302            secondDigit.addChild(thirdDigit);
1303            // The time must be finished now. E.g. 2:57am, 9:30pm.
1304            thirdDigit.addChild(ampm);
1305        }
1306    }
1307
1308    /**
1309     * Simple node class to be used for traversal to check for legal times.
1310     * mLegalKeys represents the keys that can be typed to get to the node.
1311     * mChildren are the children that can be reached from this node.
1312     */
1313    private class Node {
1314        private int[] mLegalKeys;
1315        private ArrayList<Node> mChildren;
1316
1317        public Node(int... legalKeys) {
1318            mLegalKeys = legalKeys;
1319            mChildren = new ArrayList<Node>();
1320        }
1321
1322        public void addChild(Node child) {
1323            mChildren.add(child);
1324        }
1325
1326        public boolean containsKey(int key) {
1327            for (int i = 0; i < mLegalKeys.length; i++) {
1328                if (mLegalKeys[i] == key) {
1329                    return true;
1330                }
1331            }
1332            return false;
1333        }
1334
1335        public Node canReach(int key) {
1336            if (mChildren == null) {
1337                return null;
1338            }
1339            for (Node child : mChildren) {
1340                if (child.containsKey(key)) {
1341                    return child;
1342                }
1343            }
1344            return null;
1345        }
1346    }
1347
1348    private class KeyboardListener implements View.OnKeyListener {
1349        @Override
1350        public boolean onKey(View v, int keyCode, KeyEvent event) {
1351            if (event.getAction() == KeyEvent.ACTION_UP) {
1352                return processKeyUp(keyCode);
1353            }
1354            return false;
1355        }
1356    }
1357
1358    /**
1359     * Render an animator to pulsate a view in place.
1360     *
1361     * @param labelToAnimate the view to pulsate.
1362     * @return The animator object. Use .start() to begin.
1363     */
1364    private static ObjectAnimator getPulseAnimator(View labelToAnimate, float decreaseRatio,
1365            float increaseRatio) {
1366        final Keyframe k0 = Keyframe.ofFloat(0f, 1f);
1367        final Keyframe k1 = Keyframe.ofFloat(0.275f, decreaseRatio);
1368        final Keyframe k2 = Keyframe.ofFloat(0.69f, increaseRatio);
1369        final Keyframe k3 = Keyframe.ofFloat(1f, 1f);
1370
1371        PropertyValuesHolder scaleX = PropertyValuesHolder.ofKeyframe(View.SCALE_X, k0, k1, k2, k3);
1372        PropertyValuesHolder scaleY = PropertyValuesHolder.ofKeyframe(View.SCALE_Y, k0, k1, k2, k3);
1373        ObjectAnimator pulseAnimator =
1374                ObjectAnimator.ofPropertyValuesHolder(labelToAnimate, scaleX, scaleY);
1375        pulseAnimator.setDuration(PULSE_ANIMATOR_DURATION);
1376
1377        return pulseAnimator;
1378    }
1379}
1380