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