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