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