TimePickerClockDelegate.java revision b3f24639902e71d4da3b2aa4eff25e75e9ce7711
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.annotation.Nullable;
20import android.content.Context;
21import android.content.res.ColorStateList;
22import android.content.res.Configuration;
23import android.content.res.Resources;
24import android.content.res.TypedArray;
25import android.os.Parcel;
26import android.os.Parcelable;
27import android.text.SpannableStringBuilder;
28import android.text.format.DateFormat;
29import android.text.format.DateUtils;
30import android.text.style.TtsSpan;
31import android.util.AttributeSet;
32import android.util.StateSet;
33import android.view.HapticFeedbackConstants;
34import android.view.LayoutInflater;
35import android.view.MotionEvent;
36import android.view.View;
37import android.view.View.AccessibilityDelegate;
38import android.view.View.MeasureSpec;
39import android.view.ViewGroup;
40import android.view.accessibility.AccessibilityEvent;
41import android.view.accessibility.AccessibilityNodeInfo;
42import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
43
44import com.android.internal.R;
45import com.android.internal.widget.NumericTextView;
46import com.android.internal.widget.NumericTextView.OnValueChangedListener;
47
48import java.util.Calendar;
49import java.util.Locale;
50
51/**
52 * A delegate implementing the radial clock-based TimePicker.
53 */
54class TimePickerClockDelegate extends TimePicker.AbstractTimePickerDelegate implements
55        RadialTimePickerView.OnValueSelectedListener {
56    /**
57     * Delay in milliseconds before valid but potentially incomplete, for
58     * example "1" but not "12", keyboard edits are propagated from the
59     * hour / minute fields to the radial picker.
60     */
61    private static final long DELAY_COMMIT_MILLIS = 2000;
62
63    // Index used by RadialPickerLayout
64    private static final int HOUR_INDEX = 0;
65    private static final int MINUTE_INDEX = 1;
66
67    // NOT a real index for the purpose of what's showing.
68    private static final int AMPM_INDEX = 2;
69
70    private static final int[] ATTRS_TEXT_COLOR = new int[] {R.attr.textColor};
71    private static final int[] ATTRS_DISABLED_ALPHA = new int[] {R.attr.disabledAlpha};
72
73    // LayoutLib relies on these constants. Change TimePickerClockDelegate_Delegate if
74    // modifying these.
75    static final int AM = 0;
76    static final int PM = 1;
77
78    private static final int HOURS_IN_HALF_DAY = 12;
79
80    private final NumericTextView mHourView;
81    private final NumericTextView mMinuteView;
82    private final View mAmPmLayout;
83    private final RadioButton mAmLabel;
84    private final RadioButton mPmLabel;
85    private final RadialTimePickerView mRadialTimePickerView;
86    private final TextView mSeparatorView;
87
88    private boolean mIsEnabled = true;
89    private boolean mAllowAutoAdvance;
90    private int mInitialHourOfDay;
91    private int mInitialMinute;
92    private boolean mIs24HourView;
93    private boolean mIsAmPmAtStart;
94
95    // Accessibility strings.
96    private String mSelectHours;
97    private String mSelectMinutes;
98
99    // Localization data.
100    private boolean mHourFormatShowLeadingZero;
101    private boolean mHourFormatStartsAtZero;
102
103    // Most recent time announcement values for accessibility.
104    private CharSequence mLastAnnouncedText;
105    private boolean mLastAnnouncedIsHour;
106
107    private Calendar mTempCalendar;
108
109    public TimePickerClockDelegate(TimePicker delegator, Context context, AttributeSet attrs,
110            int defStyleAttr, int defStyleRes) {
111        super(delegator, context);
112
113        // process style attributes
114        final TypedArray a = mContext.obtainStyledAttributes(attrs,
115                R.styleable.TimePicker, defStyleAttr, defStyleRes);
116        final LayoutInflater inflater = (LayoutInflater) mContext.getSystemService(
117                Context.LAYOUT_INFLATER_SERVICE);
118        final Resources res = mContext.getResources();
119
120        mSelectHours = res.getString(R.string.select_hours);
121        mSelectMinutes = res.getString(R.string.select_minutes);
122
123        final int layoutResourceId = a.getResourceId(R.styleable.TimePicker_internalLayout,
124                R.layout.time_picker_material);
125        final View mainView = inflater.inflate(layoutResourceId, delegator);
126        final View headerView = mainView.findViewById(R.id.time_header);
127        headerView.setOnTouchListener(new NearestTouchDelegate());
128
129        // Set up hour/minute labels.
130        mHourView = (NumericTextView) mainView.findViewById(R.id.hours);
131        mHourView.setOnClickListener(mClickListener);
132        mHourView.setOnFocusChangeListener(mFocusListener);
133        mHourView.setOnDigitEnteredListener(mDigitEnteredListener);
134        mHourView.setAccessibilityDelegate(
135                new ClickActionDelegate(context, R.string.select_hours));
136        mSeparatorView = (TextView) mainView.findViewById(R.id.separator);
137        mMinuteView = (NumericTextView) mainView.findViewById(R.id.minutes);
138        mMinuteView.setOnClickListener(mClickListener);
139        mMinuteView.setOnFocusChangeListener(mFocusListener);
140        mMinuteView.setOnDigitEnteredListener(mDigitEnteredListener);
141        mMinuteView.setAccessibilityDelegate(
142                new ClickActionDelegate(context, R.string.select_minutes));
143        mMinuteView.setRange(0, 59);
144
145        // Set up AM/PM labels.
146        mAmPmLayout = mainView.findViewById(R.id.ampm_layout);
147        mAmPmLayout.setOnTouchListener(new NearestTouchDelegate());
148
149        final String[] amPmStrings = TimePicker.getAmPmStrings(context);
150        mAmLabel = (RadioButton) mAmPmLayout.findViewById(R.id.am_label);
151        mAmLabel.setText(obtainVerbatim(amPmStrings[0]));
152        mAmLabel.setOnClickListener(mClickListener);
153        ensureMinimumTextWidth(mAmLabel);
154
155        mPmLabel = (RadioButton) mAmPmLayout.findViewById(R.id.pm_label);
156        mPmLabel.setText(obtainVerbatim(amPmStrings[1]));
157        mPmLabel.setOnClickListener(mClickListener);
158        ensureMinimumTextWidth(mPmLabel);
159
160        // For the sake of backwards compatibility, attempt to extract the text
161        // color from the header time text appearance. If it's set, we'll let
162        // that override the "real" header text color.
163        ColorStateList headerTextColor = null;
164
165        @SuppressWarnings("deprecation")
166        final int timeHeaderTextAppearance = a.getResourceId(
167                R.styleable.TimePicker_headerTimeTextAppearance, 0);
168        if (timeHeaderTextAppearance != 0) {
169            final TypedArray textAppearance = mContext.obtainStyledAttributes(null,
170                    ATTRS_TEXT_COLOR, 0, timeHeaderTextAppearance);
171            final ColorStateList legacyHeaderTextColor = textAppearance.getColorStateList(0);
172            headerTextColor = applyLegacyColorFixes(legacyHeaderTextColor);
173            textAppearance.recycle();
174        }
175
176        if (headerTextColor == null) {
177            headerTextColor = a.getColorStateList(R.styleable.TimePicker_headerTextColor);
178        }
179
180        if (headerTextColor != null) {
181            mHourView.setTextColor(headerTextColor);
182            mSeparatorView.setTextColor(headerTextColor);
183            mMinuteView.setTextColor(headerTextColor);
184            mAmLabel.setTextColor(headerTextColor);
185            mPmLabel.setTextColor(headerTextColor);
186        }
187
188        // Set up header background, if available.
189        if (a.hasValueOrEmpty(R.styleable.TimePicker_headerBackground)) {
190            headerView.setBackground(a.getDrawable(R.styleable.TimePicker_headerBackground));
191        }
192
193        a.recycle();
194
195        mRadialTimePickerView = (RadialTimePickerView) mainView.findViewById(R.id.radial_picker);
196        mRadialTimePickerView.applyAttributes(attrs, defStyleAttr, defStyleRes);
197
198        setupListeners();
199
200        mAllowAutoAdvance = true;
201
202        // Updates mHourFormat variables used below.
203        updateHourFormat(mCurrentLocale, mIs24HourView);
204
205        // Update hour text field.
206        final int minHour = mHourFormatStartsAtZero ? 0 : 1;
207        final int maxHour = (mIs24HourView ? 23 : 11) + minHour;
208        mHourView.setRange(minHour, maxHour);
209        mHourView.setShowLeadingZeroes(mHourFormatShowLeadingZero);
210
211        // Initialize with current time.
212        mTempCalendar = Calendar.getInstance(mCurrentLocale);
213        final int currentHour = mTempCalendar.get(Calendar.HOUR_OF_DAY);
214        final int currentMinute = mTempCalendar.get(Calendar.MINUTE);
215        initialize(currentHour, currentMinute, mIs24HourView, HOUR_INDEX);
216    }
217
218    /**
219     * Ensures that a TextView is wide enough to contain its text without
220     * wrapping or clipping. Measures the specified view and sets the minimum
221     * width to the view's desired width.
222     *
223     * @param v the text view to measure
224     */
225    private static void ensureMinimumTextWidth(TextView v) {
226        v.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
227
228        // Set both the TextView and the View version of minimum
229        // width because they are subtly different.
230        final int minWidth = v.getMeasuredWidth();
231        v.setMinWidth(minWidth);
232        v.setMinimumWidth(minWidth);
233    }
234
235    /**
236     * Determines how the hour should be formatted and updates member variables
237     * related to hour formatting.
238     *
239     * @param locale the locale in which the view is displayed
240     * @param is24Hour whether the view is in 24-hour (hour-of-day) mode
241     */
242    private void updateHourFormat(Locale locale, boolean is24Hour) {
243        final String bestDateTimePattern = DateFormat.getBestDateTimePattern(
244                locale, is24Hour ? "Hm" : "hm");
245        final int lengthPattern = bestDateTimePattern.length();
246        boolean showLeadingZero = false;
247        char hourFormat = '\0';
248
249        for (int i = 0; i < lengthPattern; i++) {
250            final char c = bestDateTimePattern.charAt(i);
251            if (c == 'H' || c == 'h' || c == 'K' || c == 'k') {
252                hourFormat = c;
253                if (i + 1 < lengthPattern && c == bestDateTimePattern.charAt(i + 1)) {
254                    showLeadingZero = true;
255                }
256                break;
257            }
258        }
259
260        mHourFormatShowLeadingZero = showLeadingZero;
261        mHourFormatStartsAtZero = hourFormat == 'K' || hourFormat == 'H';
262    }
263
264    private static final CharSequence obtainVerbatim(String text) {
265        return new SpannableStringBuilder().append(text,
266                new TtsSpan.VerbatimBuilder(text).build(), 0);
267    }
268
269    /**
270     * The legacy text color might have been poorly defined. Ensures that it
271     * has an appropriate activated state, using the selected state if one
272     * exists or modifying the default text color otherwise.
273     *
274     * @param color a legacy text color, or {@code null}
275     * @return a color state list with an appropriate activated state, or
276     *         {@code null} if a valid activated state could not be generated
277     */
278    @Nullable
279    private ColorStateList applyLegacyColorFixes(@Nullable ColorStateList color) {
280        if (color == null || color.hasState(R.attr.state_activated)) {
281            return color;
282        }
283
284        final int activatedColor;
285        final int defaultColor;
286        if (color.hasState(R.attr.state_selected)) {
287            activatedColor = color.getColorForState(StateSet.get(
288                    StateSet.VIEW_STATE_ENABLED | StateSet.VIEW_STATE_SELECTED), 0);
289            defaultColor = color.getColorForState(StateSet.get(
290                    StateSet.VIEW_STATE_ENABLED), 0);
291        } else {
292            activatedColor = color.getDefaultColor();
293
294            // Generate a non-activated color using the disabled alpha.
295            final TypedArray ta = mContext.obtainStyledAttributes(ATTRS_DISABLED_ALPHA);
296            final float disabledAlpha = ta.getFloat(0, 0.30f);
297            defaultColor = multiplyAlphaComponent(activatedColor, disabledAlpha);
298        }
299
300        if (activatedColor == 0 || defaultColor == 0) {
301            // We somehow failed to obtain the colors.
302            return null;
303        }
304
305        final int[][] stateSet = new int[][] {{ R.attr.state_activated }, {}};
306        final int[] colors = new int[] { activatedColor, defaultColor };
307        return new ColorStateList(stateSet, colors);
308    }
309
310    private int multiplyAlphaComponent(int color, float alphaMod) {
311        final int srcRgb = color & 0xFFFFFF;
312        final int srcAlpha = (color >> 24) & 0xFF;
313        final int dstAlpha = (int) (srcAlpha * alphaMod + 0.5f);
314        return srcRgb | (dstAlpha << 24);
315    }
316
317    private static class ClickActionDelegate extends AccessibilityDelegate {
318        private final AccessibilityAction mClickAction;
319
320        public ClickActionDelegate(Context context, int resId) {
321            mClickAction = new AccessibilityAction(
322                    AccessibilityNodeInfo.ACTION_CLICK, context.getString(resId));
323        }
324
325        @Override
326        public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) {
327            super.onInitializeAccessibilityNodeInfo(host, info);
328
329            info.addAction(mClickAction);
330        }
331    }
332
333    private void initialize(int hourOfDay, int minute, boolean is24HourView, int index) {
334        mInitialHourOfDay = hourOfDay;
335        mInitialMinute = minute;
336        mIs24HourView = is24HourView;
337        updateUI(index);
338    }
339
340    private void setupListeners() {
341        mRadialTimePickerView.setOnValueSelectedListener(this);
342    }
343
344    private void updateUI(int index) {
345        updateHeaderAmPm();
346        updateHeaderHour(mInitialHourOfDay, false);
347        updateHeaderSeparator();
348        updateHeaderMinute(mInitialMinute, false);
349        updateRadialPicker(index);
350
351        mDelegator.invalidate();
352    }
353
354    private void updateRadialPicker(int index) {
355        mRadialTimePickerView.initialize(mInitialHourOfDay, mInitialMinute, mIs24HourView);
356        setCurrentItemShowing(index, false, true);
357    }
358
359    private void updateHeaderAmPm() {
360
361        if (mIs24HourView) {
362            mAmPmLayout.setVisibility(View.GONE);
363        } else {
364            // Ensure that AM/PM layout is in the correct position.
365            final String dateTimePattern = DateFormat.getBestDateTimePattern(mCurrentLocale, "hm");
366            final boolean isAmPmAtStart = dateTimePattern.startsWith("a");
367            setAmPmAtStart(isAmPmAtStart);
368
369            updateAmPmLabelStates(mInitialHourOfDay < 12 ? AM : PM);
370        }
371    }
372
373    private void setAmPmAtStart(boolean isAmPmAtStart) {
374        if (mIsAmPmAtStart != isAmPmAtStart) {
375            mIsAmPmAtStart = isAmPmAtStart;
376
377            final RelativeLayout.LayoutParams params =
378                    (RelativeLayout.LayoutParams) mAmPmLayout.getLayoutParams();
379            if (params.getRule(RelativeLayout.RIGHT_OF) != 0 ||
380                    params.getRule(RelativeLayout.LEFT_OF) != 0) {
381                if (isAmPmAtStart) {
382                    params.removeRule(RelativeLayout.RIGHT_OF);
383                    params.addRule(RelativeLayout.LEFT_OF, mHourView.getId());
384                } else {
385                    params.removeRule(RelativeLayout.LEFT_OF);
386                    params.addRule(RelativeLayout.RIGHT_OF, mMinuteView.getId());
387                }
388            }
389
390            mAmPmLayout.setLayoutParams(params);
391        }
392    }
393
394    /**
395     * Set the current hour.
396     */
397    @Override
398    public void setCurrentHour(int currentHour) {
399        if (mInitialHourOfDay == currentHour) {
400            return;
401        }
402        mInitialHourOfDay = currentHour;
403        updateHeaderHour(currentHour, true);
404        updateHeaderAmPm();
405        mRadialTimePickerView.setCurrentHour(currentHour);
406        mRadialTimePickerView.setAmOrPm(mInitialHourOfDay < 12 ? AM : PM);
407        mDelegator.invalidate();
408        onTimeChanged();
409    }
410
411    /**
412     * @return The current hour in the range (0-23).
413     */
414    @Override
415    public int getCurrentHour() {
416        int currentHour = mRadialTimePickerView.getCurrentHour();
417        if (mIs24HourView) {
418            return currentHour;
419        } else {
420            switch(mRadialTimePickerView.getAmOrPm()) {
421                case PM:
422                    return (currentHour % HOURS_IN_HALF_DAY) + HOURS_IN_HALF_DAY;
423                case AM:
424                default:
425                    return currentHour % HOURS_IN_HALF_DAY;
426            }
427        }
428    }
429
430    /**
431     * Set the current minute (0-59).
432     */
433    @Override
434    public void setCurrentMinute(int currentMinute) {
435        if (mInitialMinute == currentMinute) {
436            return;
437        }
438        mInitialMinute = currentMinute;
439        updateHeaderMinute(currentMinute, true);
440        mRadialTimePickerView.setCurrentMinute(currentMinute);
441        mDelegator.invalidate();
442        onTimeChanged();
443    }
444
445    /**
446     * @return The current minute.
447     */
448    @Override
449    public int getCurrentMinute() {
450        return mRadialTimePickerView.getCurrentMinute();
451    }
452
453    /**
454     * Set whether in 24 hour or AM/PM mode.
455     *
456     * @param is24HourView True = 24 hour mode. False = AM/PM.
457     */
458    @Override
459    public void setIs24HourView(boolean is24HourView) {
460        if (is24HourView == mIs24HourView) {
461            return;
462        }
463
464        mIs24HourView = is24HourView;
465        mInitialHourOfDay = getCurrentHour();
466
467        updateUI(mRadialTimePickerView.getCurrentItemShowing());
468    }
469
470    /**
471     * @return true if this is in 24 hour view else false.
472     */
473    @Override
474    public boolean is24HourView() {
475        return mIs24HourView;
476    }
477
478    @Override
479    public void setOnTimeChangedListener(TimePicker.OnTimeChangedListener callback) {
480        mOnTimeChangedListener = callback;
481    }
482
483    @Override
484    public void setEnabled(boolean enabled) {
485        mHourView.setEnabled(enabled);
486        mMinuteView.setEnabled(enabled);
487        mAmLabel.setEnabled(enabled);
488        mPmLabel.setEnabled(enabled);
489        mRadialTimePickerView.setEnabled(enabled);
490        mIsEnabled = enabled;
491    }
492
493    @Override
494    public boolean isEnabled() {
495        return mIsEnabled;
496    }
497
498    @Override
499    public int getBaseline() {
500        // does not support baseline alignment
501        return -1;
502    }
503
504    @Override
505    public void onConfigurationChanged(Configuration newConfig) {
506        updateUI(mRadialTimePickerView.getCurrentItemShowing());
507    }
508
509    @Override
510    public Parcelable onSaveInstanceState(Parcelable superState) {
511        return new SavedState(superState, getCurrentHour(), getCurrentMinute(),
512                is24HourView(), getCurrentItemShowing());
513    }
514
515    @Override
516    public void onRestoreInstanceState(Parcelable state) {
517        final SavedState ss = (SavedState) state;
518        initialize(ss.getHour(), ss.getMinute(), ss.is24HourMode(), ss.getCurrentItemShowing());
519        mRadialTimePickerView.invalidate();
520    }
521
522    @Override
523    public void setCurrentLocale(Locale locale) {
524        super.setCurrentLocale(locale);
525        mTempCalendar = Calendar.getInstance(locale);
526    }
527
528    @Override
529    public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) {
530        onPopulateAccessibilityEvent(event);
531        return true;
532    }
533
534    @Override
535    public void onPopulateAccessibilityEvent(AccessibilityEvent event) {
536        int flags = DateUtils.FORMAT_SHOW_TIME;
537        if (mIs24HourView) {
538            flags |= DateUtils.FORMAT_24HOUR;
539        } else {
540            flags |= DateUtils.FORMAT_12HOUR;
541        }
542        mTempCalendar.set(Calendar.HOUR_OF_DAY, getCurrentHour());
543        mTempCalendar.set(Calendar.MINUTE, getCurrentMinute());
544        String selectedDate = DateUtils.formatDateTime(mContext,
545                mTempCalendar.getTimeInMillis(), flags);
546        event.getText().add(selectedDate);
547    }
548
549    /**
550     * @return the index of the current item showing
551     */
552    private int getCurrentItemShowing() {
553        return mRadialTimePickerView.getCurrentItemShowing();
554    }
555
556    /**
557     * Propagate the time change
558     */
559    private void onTimeChanged() {
560        mDelegator.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED);
561        if (mOnTimeChangedListener != null) {
562            mOnTimeChangedListener.onTimeChanged(mDelegator,
563                    getCurrentHour(), getCurrentMinute());
564        }
565    }
566
567    /**
568     * Used to save / restore state of time picker
569     */
570    private static class SavedState extends View.BaseSavedState {
571
572        private final int mHour;
573        private final int mMinute;
574        private final boolean mIs24HourMode;
575        private final int mCurrentItemShowing;
576
577        private SavedState(Parcelable superState, int hour, int minute, boolean is24HourMode,
578                int currentItemShowing) {
579            super(superState);
580            mHour = hour;
581            mMinute = minute;
582            mIs24HourMode = is24HourMode;
583            mCurrentItemShowing = currentItemShowing;
584        }
585
586        private SavedState(Parcel in) {
587            super(in);
588            mHour = in.readInt();
589            mMinute = in.readInt();
590            mIs24HourMode = (in.readInt() == 1);
591            mCurrentItemShowing = in.readInt();
592        }
593
594        public int getHour() {
595            return mHour;
596        }
597
598        public int getMinute() {
599            return mMinute;
600        }
601
602        public boolean is24HourMode() {
603            return mIs24HourMode;
604        }
605
606        public int getCurrentItemShowing() {
607            return mCurrentItemShowing;
608        }
609
610        @Override
611        public void writeToParcel(Parcel dest, int flags) {
612            super.writeToParcel(dest, flags);
613            dest.writeInt(mHour);
614            dest.writeInt(mMinute);
615            dest.writeInt(mIs24HourMode ? 1 : 0);
616            dest.writeInt(mCurrentItemShowing);
617        }
618
619        @SuppressWarnings({"unused", "hiding"})
620        public static final Creator<SavedState> CREATOR = new Creator<SavedState>() {
621            public SavedState createFromParcel(Parcel in) {
622                return new SavedState(in);
623            }
624
625            public SavedState[] newArray(int size) {
626                return new SavedState[size];
627            }
628        };
629    }
630
631    private void tryVibrate() {
632        mDelegator.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK);
633    }
634
635    private void updateAmPmLabelStates(int amOrPm) {
636        final boolean isAm = amOrPm == AM;
637        mAmLabel.setActivated(isAm);
638        mAmLabel.setChecked(isAm);
639
640        final boolean isPm = amOrPm == PM;
641        mPmLabel.setActivated(isPm);
642        mPmLabel.setChecked(isPm);
643    }
644
645    /**
646     * Called by the picker for updating the header display.
647     */
648    @Override
649    public void onValueSelected(int pickerIndex, int newValue, boolean autoAdvance) {
650        switch (pickerIndex) {
651            case HOUR_INDEX:
652                if (mAllowAutoAdvance && autoAdvance) {
653                    updateHeaderHour(newValue, false);
654                    setCurrentItemShowing(MINUTE_INDEX, true, false);
655                    mDelegator.announceForAccessibility(newValue + ". " + mSelectMinutes);
656                } else {
657                    updateHeaderHour(newValue, true);
658                }
659                break;
660            case MINUTE_INDEX:
661                updateHeaderMinute(newValue, true);
662                break;
663            case AMPM_INDEX:
664                updateAmPmLabelStates(newValue);
665                break;
666        }
667
668        if (mOnTimeChangedListener != null) {
669            mOnTimeChangedListener.onTimeChanged(mDelegator, getCurrentHour(), getCurrentMinute());
670        }
671    }
672
673    /**
674     * Converts hour-of-day (0-23) time into a localized hour number.
675     *
676     * @param hourOfDay the hour-of-day (0-23)
677     * @return a localized hour number
678     */
679    private int getLocalizedHour(int hourOfDay) {
680        if (!mIs24HourView) {
681            // Convert to hour-of-am-pm.
682            hourOfDay %= 12;
683        }
684
685        if (!mHourFormatStartsAtZero && hourOfDay == 0) {
686            // Convert to clock-hour (either of-day or of-am-pm).
687            hourOfDay = mIs24HourView ? 24 : 12;
688        }
689
690        return hourOfDay;
691    }
692
693    private void updateHeaderHour(int hourOfDay, boolean announce) {
694        final int localizedHour = getLocalizedHour(hourOfDay);
695        mHourView.setValue(localizedHour);
696
697        if (announce) {
698            tryAnnounceForAccessibility(mHourView.getText(), true);
699        }
700    }
701
702    private void updateHeaderMinute(int minuteOfHour, boolean announce) {
703        mMinuteView.setValue(minuteOfHour);
704
705        if (announce) {
706            tryAnnounceForAccessibility(mMinuteView.getText(), false);
707        }
708    }
709
710    /**
711     * The time separator is defined in the Unicode CLDR and cannot be supposed to be ":".
712     *
713     * See http://unicode.org/cldr/trac/browser/trunk/common/main
714     *
715     * We pass the correct "skeleton" depending on 12 or 24 hours view and then extract the
716     * separator as the character which is just after the hour marker in the returned pattern.
717     */
718    private void updateHeaderSeparator() {
719        final String bestDateTimePattern = DateFormat.getBestDateTimePattern(mCurrentLocale,
720                (mIs24HourView) ? "Hm" : "hm");
721        final String separatorText;
722        // See http://www.unicode.org/reports/tr35/tr35-dates.html for hour formats
723        final char[] hourFormats = {'H', 'h', 'K', 'k'};
724        int hIndex = lastIndexOfAny(bestDateTimePattern, hourFormats);
725        if (hIndex == -1) {
726            // Default case
727            separatorText = ":";
728        } else {
729            separatorText = Character.toString(bestDateTimePattern.charAt(hIndex + 1));
730        }
731        mSeparatorView.setText(separatorText);
732    }
733
734    static private int lastIndexOfAny(String str, char[] any) {
735        final int lengthAny = any.length;
736        if (lengthAny > 0) {
737            for (int i = str.length() - 1; i >= 0; i--) {
738                char c = str.charAt(i);
739                for (int j = 0; j < lengthAny; j++) {
740                    if (c == any[j]) {
741                        return i;
742                    }
743                }
744            }
745        }
746        return -1;
747    }
748
749    private void tryAnnounceForAccessibility(CharSequence text, boolean isHour) {
750        if (mLastAnnouncedIsHour != isHour || !text.equals(mLastAnnouncedText)) {
751            // TODO: Find a better solution, potentially live regions?
752            mDelegator.announceForAccessibility(text);
753            mLastAnnouncedText = text;
754            mLastAnnouncedIsHour = isHour;
755        }
756    }
757
758    /**
759     * Show either Hours or Minutes.
760     */
761    private void setCurrentItemShowing(int index, boolean animateCircle, boolean announce) {
762        mRadialTimePickerView.setCurrentItemShowing(index, animateCircle);
763
764        if (index == HOUR_INDEX) {
765            if (announce) {
766                mDelegator.announceForAccessibility(mSelectHours);
767            }
768        } else {
769            if (announce) {
770                mDelegator.announceForAccessibility(mSelectMinutes);
771            }
772        }
773
774        mHourView.setActivated(index == HOUR_INDEX);
775        mMinuteView.setActivated(index == MINUTE_INDEX);
776    }
777
778    private void setAmOrPm(int amOrPm) {
779        updateAmPmLabelStates(amOrPm);
780        mRadialTimePickerView.setAmOrPm(amOrPm);
781    }
782
783    private final OnValueChangedListener mDigitEnteredListener = new OnValueChangedListener() {
784        @Override
785        public void onValueChanged(NumericTextView view, int value,
786                boolean isValid, boolean isFinished) {
787            final Runnable commitCallback;
788            final View nextFocusTarget;
789            if (view == mHourView) {
790                commitCallback = mCommitHour;
791                nextFocusTarget = view.isFocused() ? mMinuteView : null;
792            } else if (view == mMinuteView) {
793                commitCallback = mCommitMinute;
794                nextFocusTarget = null;
795            } else {
796                return;
797            }
798
799            view.removeCallbacks(commitCallback);
800
801            if (isValid) {
802                if (isFinished) {
803                    // Done with hours entry, make visual updates
804                    // immediately and move to next focus if needed.
805                    commitCallback.run();
806
807                    if (nextFocusTarget != null) {
808                        nextFocusTarget.requestFocus();
809                    }
810                } else {
811                    // May still be making changes. Postpone visual
812                    // updates to prevent distracting the user.
813                    view.postDelayed(commitCallback, DELAY_COMMIT_MILLIS);
814                }
815            }
816        }
817    };
818
819    private final Runnable mCommitHour = new Runnable() {
820        @Override
821        public void run() {
822            setCurrentHour(mHourView.getValue());
823        }
824    };
825
826    private final Runnable mCommitMinute = new Runnable() {
827        @Override
828        public void run() {
829            setCurrentMinute(mMinuteView.getValue());
830        }
831    };
832
833    private final View.OnFocusChangeListener mFocusListener = new View.OnFocusChangeListener() {
834        @Override
835        public void onFocusChange(View v, boolean focused) {
836            if (focused) {
837                switch (v.getId()) {
838                    case R.id.am_label:
839                        setAmOrPm(AM);
840                        break;
841                    case R.id.pm_label:
842                        setAmOrPm(PM);
843                        break;
844                    case R.id.hours:
845                        setCurrentItemShowing(HOUR_INDEX, true, true);
846                        break;
847                    case R.id.minutes:
848                        setCurrentItemShowing(MINUTE_INDEX, true, true);
849                        break;
850                    default:
851                        // Failed to handle this click, don't vibrate.
852                        return;
853                }
854
855                tryVibrate();
856            }
857        }
858    };
859
860    private final View.OnClickListener mClickListener = new View.OnClickListener() {
861        @Override
862        public void onClick(View v) {
863
864            final int amOrPm;
865            switch (v.getId()) {
866                case R.id.am_label:
867                    setAmOrPm(AM);
868                    break;
869                case R.id.pm_label:
870                    setAmOrPm(PM);
871                    break;
872                case R.id.hours:
873                    setCurrentItemShowing(HOUR_INDEX, true, true);
874                    break;
875                case R.id.minutes:
876                    setCurrentItemShowing(MINUTE_INDEX, true, true);
877                    break;
878                default:
879                    // Failed to handle this click, don't vibrate.
880                    return;
881            }
882
883            tryVibrate();
884        }
885    };
886
887    /**
888     * Delegates unhandled touches in a view group to the nearest child view.
889     */
890    private static class NearestTouchDelegate implements View.OnTouchListener {
891            private View mInitialTouchTarget;
892
893            @Override
894            public boolean onTouch(View view, MotionEvent motionEvent) {
895                final int actionMasked = motionEvent.getActionMasked();
896                if (actionMasked == MotionEvent.ACTION_DOWN) {
897                    mInitialTouchTarget = findNearestChild((ViewGroup) view,
898                            (int) motionEvent.getX(), (int) motionEvent.getY());
899                }
900
901                final View child = mInitialTouchTarget;
902                if (child == null) {
903                    return false;
904                }
905
906                final float offsetX = view.getScrollX() - child.getLeft();
907                final float offsetY = view.getScrollY() - child.getTop();
908                motionEvent.offsetLocation(offsetX, offsetY);
909                final boolean handled = child.dispatchTouchEvent(motionEvent);
910                motionEvent.offsetLocation(-offsetX, -offsetY);
911
912                if (actionMasked == MotionEvent.ACTION_UP
913                        || actionMasked == MotionEvent.ACTION_CANCEL) {
914                    mInitialTouchTarget = null;
915                }
916
917                return handled;
918            }
919
920        private View findNearestChild(ViewGroup v, int x, int y) {
921            View bestChild = null;
922            int bestDist = Integer.MAX_VALUE;
923
924            for (int i = 0, count = v.getChildCount(); i < count; i++) {
925                final View child = v.getChildAt(i);
926                final int dX = x - (child.getLeft() + child.getWidth() / 2);
927                final int dY = y - (child.getTop() + child.getHeight() / 2);
928                final int dist = dX * dX + dY * dY;
929                if (bestDist > dist) {
930                    bestChild = child;
931                    bestDist = dist;
932                }
933            }
934
935            return bestChild;
936        }
937    }
938}
939